Repository: Khan/perseus
Pull request #2286: Add scoring criteria to Free Response Widget
Author: login: aag
Created: 2025-03-06T21:23:08Z | Merged: 2025-03-12T12:37:28Z

## Summary:
This commit adds a new scoringCriteria option to the Free Response
Widget editor. It allows users to input one or more scoring criteria
texts, which can be used when grading the answers to a Free Response
question.

This commit also changes the "Question" input to be a textarea instead
of an input element. This allows longer questions to wrap and be fully
visible even when the widget editor is narrow.

Issue: https://khanacademy.atlassian.net/browse/LIT-1663

## Test plan:
- Run `pnpm test packages/perseus-editor/src/widgets/__tests__/free-response-editor.test.tsx`
- Open Storybook.
- Open the Free Response Editor stories.
- Verify that the rendered stories look right.
- Interact with the "Editable" story, verifying that it's possible to
  add new criteria and delete existing ones.
- Verify that the "Delete" button is grayed out on the "One Criterion" story.
![image](https://github.com/user-attachments/assets/5e431b75-e017-400a-8534-4471b82e2c27)



Diff:
diff --git a/.changeset/mean-items-share.md b/.changeset/mean-items-share.md
new file mode 100644
index 00000000000..e5161695272
--- /dev/null
+++ b/.changeset/mean-items-share.md
@@ -0,0 +1,8 @@
+---
+"@khanacademy/perseus": patch
+"@khanacademy/perseus-core": patch
+"@khanacademy/perseus-editor": patch
+"@khanacademy/perseus-score": patch
+---
+
+Add scoring criteria to the Free Response Widget
diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts
index 4b428ebd15f..db9dbba08e3 100644
--- a/packages/perseus-core/src/data-schema.ts
+++ b/packages/perseus-core/src/data-schema.ts
@@ -1666,8 +1666,20 @@ export type PerseusPythonProgramWidgetOptions = {
     height: number;
 };
 
+// This is an object instead of just a string because we think we'll want to add more
+// fields in the future, like a weight, which would allow us to give partial credit
+// and weight each criterion separately.
+export type PerseusFreeResponseWidgetScoringCriterion = {
+    // An English-language description of how to score the response for this criterion.
+    text: string;
+};
+
 export type PerseusFreeResponseWidgetOptions = {
+    // The question text that will be displayed to the user.
     question: string;
+    // A list of scoring criteria for the free response question. This is a list
+    // of things the answer should contain to be considered correct.
+    scoringCriteria: ReadonlyArray<PerseusFreeResponseWidgetScoringCriterion>;
 };
 
 export type PerseusIFrameWidgetOptions = {
diff --git a/packages/perseus-core/src/index.ts b/packages/perseus-core/src/index.ts
index eee15d80b76..b12970d154a 100644
--- a/packages/perseus-core/src/index.ts
+++ b/packages/perseus-core/src/index.ts
@@ -129,6 +129,7 @@ export {default as getOrdererPublicWidgetOptions} from "./widgets/orderer/ordere
 export {default as getCategorizerPublicWidgetOptions} from "./widgets/categorizer/categorizer-util";
 export {default as getCSProgramPublicWidgetOptions} from "./widgets/cs-program/cs-program-util";
 export {default as getExpressionPublicWidgetOptions} from "./widgets/expression/expression-util";
+export {default as getFreeResponsePublicWidgetOptions} from "./widgets/free-response/free-response-util";
 export {default as getGrapherPublicWidgetOptions} from "./widgets/grapher/grapher-util";
 export {
     default as getInteractiveGraphPublicWidgetOptions,
diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.test.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.test.ts
index b8b47a6fa37..045acae1c5a 100644
--- a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.test.ts
+++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.test.ts
@@ -14,6 +14,11 @@ describe("freeResponseWidget", () => {
             graded: false,
             options: {
                 question: "What is your favorite color?",
+                scoringCriteria: [
+                    {
+                        text: "test-criterion",
+                    },
+                ],
             },
         };
 
@@ -27,6 +32,11 @@ describe("freeResponseWidget", () => {
                 graded: false,
                 options: {
                     question: "What is your favorite color?",
+                    scoringCriteria: [
+                        {
+                            text: "test-criterion",
+                        },
+                    ],
                 },
             }),
         );
diff --git a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.ts b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.ts
index df71173ec7c..9acf16e9127 100644
--- a/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.ts
+++ b/packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.ts
@@ -1,4 +1,4 @@
-import {constant, object, string} from "../general-purpose-parsers";
+import {array, constant, object, string} from "../general-purpose-parsers";
 
 import {parseWidget} from "./widget";
 
@@ -9,5 +9,10 @@ export const parseFreeResponseWidget: Parser<FreeResponseWidget> = parseWidget(
     constant("free-response"),
     object({
         question: string,
+        scoringCriteria: array(
+            object({
+                text: string,
+            }),
+        ),
     }),
 );
diff --git a/packages/perseus-core/src/widgets/free-response/free-response-util.test.ts b/packages/perseus-core/src/widgets/free-response/free-response-util.test.ts
new file mode 100644
index 00000000000..da5fcd311b6
--- /dev/null
+++ b/packages/perseus-core/src/widgets/free-response/free-response-util.test.ts
@@ -0,0 +1,25 @@
+import getFreeResponsePublicWidgetOptions from "./free-response-util";
+
+import type {PerseusFreeResponseWidgetOptions} from "../../data-schema";
+
+describe("getFreeResponsePublicWidgetOptions", () => {
+    it("should return the correct public options without any answer data", () => {
+        // Arrange
+        const options: PerseusFreeResponseWidgetOptions = {
+            question: "What is the wind speed velocity of an unladen swallow?",
+            scoringCriteria: [
+                {
+                    text: "Depends on whether it's an African or European swallow",
+                },
+            ],
+        };
+
+        // Act
+        const publicWidgetOptions = getFreeResponsePublicWidgetOptions(options);
+
+        // Assert
+        expect(publicWidgetOptions).toEqual({
+            question: "What is the wind speed velocity of an unladen swallow?",
+        });
+    });
+});
diff --git a/packages/perseus-core/src/widgets/free-response/free-response-util.ts b/packages/perseus-core/src/widgets/free-response/free-response-util.ts
new file mode 100644
index 00000000000..5bbad5e1bf5
--- /dev/null
+++ b/packages/perseus-core/src/widgets/free-response/free-response-util.ts
@@ -0,0 +1,23 @@
+import type {PerseusFreeResponseWidgetOptions} from "@khanacademy/perseus-core";
+
+/**
+ * For details on the individual options, see the
+ * {@link PerseusFreeResponseWidgetOptions} type
+ */
+export type FreeResponsePublicWidgetOptions = {
+    question: PerseusFreeResponseWidgetOptions["question"];
+};
+
+/**
+ * Given a FreeResponsePublicWidgetOptions object, return a new object with only
+ * the public options that should be exposed to the client.
+ */
+function getFreeResponsePublicWidgetOptions(
+    options: PerseusFreeResponseWidgetOptions,
+): FreeResponsePublicWidgetOptions {
+    return {
+        question: options.question,
+    };
+}
+
+export default getFreeResponsePublicWidgetOptions;
diff --git a/packages/perseus-core/src/widgets/free-response/index.ts b/packages/perseus-core/src/widgets/free-response/index.ts
index 0213679ec96..4996d7094d8 100644
--- a/packages/perseus-core/src/widgets/free-response/index.ts
+++ b/packages/perseus-core/src/widgets/free-response/index.ts
@@ -1,18 +1,29 @@
+import getFreeResponsePublicWidgetOptions from "./free-response-util";
+
 import type {PerseusFreeResponseWidgetOptions} from "../../data-schema";
 import type {WidgetLogic} from "../logic-export.types";
 
 export type FreeResponseDefaultWidgetOptions = Pick<
     PerseusFreeResponseWidgetOptions,
-    "question"
+    "question" | "scoringCriteria"
 >;
 
 const defaultWidgetOptions: FreeResponseDefaultWidgetOptions = {
     question: "",
+
+    // Always display one criterion, since the user will always have to input
+    // at least one.
+    scoringCriteria: [
+        {
+            text: "",
+        },
+    ],
 };
 
 const freeResponseWidgetLogic: WidgetLogic = {
     name: "free-response",
     defaultWidgetOptions,
+    getPublicWidgetOptions: getFreeResponsePublicWidgetOptions,
 };
 
 export default freeResponseWidgetLogic;
diff --git a/packages/perseus-core/src/widgets/logic-export.types.ts b/packages/perseus-core/src/widgets/logic-export.types.ts
index c87222346d8..8eec1986a82 100644
--- a/packages/perseus-core/src/widgets/logic-export.types.ts
+++ b/packages/perseus-core/src/widgets/logic-export.types.ts
@@ -2,6 +2,7 @@ import type getCategorizerPublicWidgetOptions from "./categorizer/categorizer-ut
 import type getCSProgramPublicWidgetOptions from "./cs-program/cs-program-util";
 import type getDropdownPublicWidgetOptions from "./dropdown/dropdown-util";
 import type getExpressionPublicWidgetOptions from "./expression/expression-util";
+import type getFreeResponsePublicWidgetOptions from "./free-response/free-response-util";
 import type getGrapherPublicWidgetOptions from "./grapher/grapher-util";
 import type getIFramePublicWidgetOptions from "./iframe/iframe-util";
 import type getInteractiveGraphPublicWidgetOptions from "./interactive-graph/interactive-graph-util";
@@ -30,23 +31,24 @@ export type WidgetOptionsUpgradeMap = {
  * so reliant on a set group of widgets
  */
 export type PublicWidgetOptionsFunction =
-    | typeof getMatcherPublicWidgetOptions
-    | typeof getPlotterPublicWidgetOptions
-    | typeof getIFramePublicWidgetOptions
-    | typeof getRadioPublicWidgetOptions
-    | typeof getNumericInputPublicWidgetOptions
-    | typeof getDropdownPublicWidgetOptions
+    | typeof getCSProgramPublicWidgetOptions
     | typeof getCategorizerPublicWidgetOptions
-    | typeof getOrdererPublicWidgetOptions
+    | typeof getDropdownPublicWidgetOptions
     | typeof getExpressionPublicWidgetOptions
+    | typeof getFreeResponsePublicWidgetOptions
+    | typeof getGrapherPublicWidgetOptions
+    | typeof getIFramePublicWidgetOptions
     | typeof getInteractiveGraphPublicWidgetOptions
     | typeof getLabelImagePublicWidgetOptions
-    | typeof getSorterPublicWidgetOptions
-    | typeof getCSProgramPublicWidgetOptions
+    | typeof getMatcherPublicWidgetOptions
+    | typeof getMatrixPublicWidgetOptions
     | typeof getNumberLinePublicWidgetOptions
-    | typeof getTablePublicWidgetOptions
-    | typeof getGrapherPublicWidgetOptions
-    | typeof getMatrixPublicWidgetOptions;
+    | typeof getNumericInputPublicWidgetOptions
+    | typeof getOrdererPublicWidgetOptions
+    | typeof getPlotterPublicWidgetOptions
+    | typeof getRadioPublicWidgetOptions
+    | typeof getSorterPublicWidgetOptions
+    | typeof getTablePublicWidgetOptions;
 
 export type WidgetLogic = {
     name: string;
diff --git a/packages/perseus-editor/src/widgets/__stories__/free-response-editor.stories.tsx b/packages/perseus-editor/src/widgets/__stories__/free-response-editor.stories.tsx
index fd7fb8be812..93a37f15243 100644
--- a/packages/perseus-editor/src/widgets/__stories__/free-response-editor.stories.tsx
+++ b/packages/perseus-editor/src/widgets/__stories__/free-response-editor.stories.tsx
@@ -1,7 +1,12 @@
+import * as React from "react";
+
 import FreeResponseEditor from "../free-response-editor";
 
+import type {PropsFor} from "@khanacademy/wonder-blocks-core";
 import type {Meta, StoryObj} from "@storybook/react";
 
+type StoryArgs = Record<any, any>;
+
 const meta: Meta<typeof FreeResponseEditor> = {
     component: FreeResponseEditor,
     title: "PerseusEditor/Widgets/Free Response Editor",
@@ -10,6 +15,51 @@ const meta: Meta<typeof FreeResponseEditor> = {
 export default meta;
 type Story = StoryObj<typeof FreeResponseEditor>;
 
-export const Primary: Story = {
+export const NoCriteria: Story = {
     args: {},
 };
+
+export const OneCriterion: Story = {
+    args: {
+        scoringCriteria: [
+            {
+                text: "50 points for Gryffindor!",
+            },
+        ],
+    },
+};
+
+export const ThreeCriteria: Story = {
+    args: {
+        scoringCriteria: [
+            {
+                text: "50 points for Gryffindor!",
+            },
+            {
+                text: "50 points for Slytherin!",
+            },
+            {
+                text: "50 points for Hufflepuff!",
+            },
+        ],
+    },
+};
+
+type State = Partial<PropsFor<typeof FreeResponseEditor>>;
+
+const WithState = () => {
+    const [state, setState] = React.useState<State>({
+        question: "What is the truth?",
+        scoringCriteria: [{text: ""}],
+    });
+
+    const onChange = (options: State) => {
+        setState({...state, ...options});
+    };
+
+    return <FreeResponseEditor {...state} onChange={onChange} />;
+};
+
+export const Editable = (args: StoryArgs): React.ReactElement => {
+    return <WithState />;
+};
diff --git a/packages/perseus-editor/src/widgets/__tests__/free-response-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/free-response-editor.test.tsx
index 07ee41a0da8..2a25d483c3b 100644
--- a/packages/perseus-editor/src/widgets/__tests__/free-response-editor.test.tsx
+++ b/packages/perseus-editor/src/widgets/__tests__/free-response-editor.test.tsx
@@ -43,6 +43,25 @@ describe("free-response editor", () => {
         expect(onChangeMock).toBeCalledWith({question: "2"});
     });
 
+    it("calls onChange when a criterion is changed", async () => {
+        // Arrange
+        const onChangeMock = jest.fn();
+
+        // Act
+        render(
+            <FreeResponseEditor
+                onChange={onChangeMock}
+                scoringCriteria={[{text: ""}]}
+            />,
+        );
+        await userEvent.type(screen.getByLabelText("Criterion 1"), "2");
+
+        // Assert
+        expect(onChangeMock).toBeCalledWith({
+            scoringCriteria: [{text: "2"}],
+        });
+    });
+
     it("returns a warning when the question is empty", async () => {
         // Arrange
         const ref = React.createRef<FreeResponseEditor>();
@@ -72,11 +91,37 @@ describe("free-response editor", () => {
         );
 
         // Assert
-        expect(ref.current?.serialize()).toEqual({
+        expect(ref.current?.serialize()).toMatchObject({
             question: "test-question",
         });
     });
 
+    it("serializes the criteria values", async () => {
+        // Arrange
+        const ref = React.createRef<FreeResponseEditor>();
+
+        // Act
+        render(
+            <FreeResponseEditor
+                ref={ref}
+                question="test-question"
+                scoringCriteria={[
+                    {text: "test-criterion-1"},
+                    {text: "test-criterion-2"},
+                ]}
+                onChange={() => {}}
+            />,
+        );
+
+        // Assert
+        expect(ref.current?.serialize()).toMatchObject({
+            scoringCriteria: [
+                {text: "test-criterion-1"},
+                {text: "test-criterion-2"},
+            ],
+        });
+    });
+
     it("the question defaults to an empty string", async () => {
         // Arrange
         const ref = React.createRef<FreeResponseEditor>();
@@ -85,8 +130,83 @@ describe("free-response editor", () => {
         render(<FreeResponseEditor ref={ref} onChange={() => {}} />);
 
         // Assert
-        expect(ref.current?.serialize()).toEqual({
+        expect(ref.current?.serialize()).toMatchObject({
             question: "",
         });
     });
+
+    it("the scoringCriteria defaults to an array with one empty value", async () => {
+        // Arrange
+        const ref = React.createRef<FreeResponseEditor>();
+
+        // Act
+        render(<FreeResponseEditor ref={ref} onChange={() => {}} />);
+
+        // Assert
+        expect(ref.current?.serialize()).toMatchObject({
+            scoringCriteria: [{text: ""}],
+        });
+    });
+
+    it("adds another criterion when the add button is clicked", async () => {
+        // Arrange
+        const onChangeMock = jest.fn();
+
+        // Act
+        render(
+            <FreeResponseEditor
+                scoringCriteria={[{text: "criterion-1"}]}
+                onChange={onChangeMock}
+            />,
+        );
+        await userEvent.click(
+            screen.getByRole("button", {name: /Add an item/i}),
+        );
+
+        // Assert
+        expect(onChangeMock).toBeCalledWith({
+            scoringCriteria: [{text: "criterion-1"}, {text: ""}],
+        });
+    });
+
+    it("prevents deleting the only criterion by hiding the delete button", async () => {
+        // Arrange
+        const onChangeMock = jest.fn();
+
+        // Act
+        render(
+            <FreeResponseEditor
+                scoringCriteria={[{text: "criterion-1"}]}
+                onChange={onChangeMock}
+            />,
+        );
+
+        expect(
+            screen.queryByRole("button", {name: /Delete/i}),
+        ).not.toBeInTheDocument();
+
+        // Assert
+        expect(onChangeMock).not.toBeCalled();
+    });
+
+    it("deletes a criterion when the delete button is clicked", async () => {
+        // Arrange
+        const onChangeMock = jest.fn();
+
+        // Act
+        render(
+            <FreeResponseEditor
+                scoringCriteria={[{text: "criterion-1"}, {text: "criterion-2"}]}
+                onChange={onChangeMock}
+            />,
+        );
+        await userEvent.click(
+            screen.getByRole("button", {name: /Delete criterion 1/i}),
+        );
+
+        // Assert
+        expect(onChangeMock).toBeCalledWith({
+            scoringCriteria: [{text: "criterion-2"}],
+        });
+    });
 });
diff --git a/packages/perseus-editor/src/widgets/free-response-editor.tsx b/packages/perseus-editor/src/widgets/free-response-editor.tsx
index 54479b26b42..f67746aad45 100644
--- a/packages/perseus-editor/src/widgets/free-response-editor.tsx
+++ b/packages/perseus-editor/src/widgets/free-response-editor.tsx
@@ -1,11 +1,17 @@
 import {freeResponseLogic} from "@khanacademy/perseus-core";
-import {LabeledTextField} from "@khanacademy/wonder-blocks-form";
-import {spacing} from "@khanacademy/wonder-blocks-tokens";
+import Button from "@khanacademy/wonder-blocks-button";
+import {View} from "@khanacademy/wonder-blocks-core";
+import {spacing, semanticColor} from "@khanacademy/wonder-blocks-tokens";
+import {HeadingSmall} from "@khanacademy/wonder-blocks-typography";
+import plusCircleIcon from "@phosphor-icons/core/regular/plus-circle.svg";
+import trashIcon from "@phosphor-icons/core/regular/trash.svg";
+import {StyleSheet, css} from "aphrodite";
 import * as React from "react";
 
 import type {
     PerseusFreeResponseWidgetOptions,
     FreeResponseDefaultWidgetOptions,
+    PerseusFreeResponseWidgetScoringCriterion,
 } from "@khanacademy/perseus-core";
 
 type Props = PerseusFreeResponseWidgetOptions & {
@@ -21,6 +27,7 @@ class FreeResponseEditor extends React.Component<Props> {
     serialize(): PerseusFreeResponseWidgetOptions {
         return {
             question: this.props.question,
+            scoringCriteria: this.props.scoringCriteria,
         };
     }
 
@@ -32,18 +39,148 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        const isDeletable = this.props.scoringCriteria.length > 1;
+
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    isDeletable={isDeletable}
+                    key={index}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    isDeletable: boolean;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                aria-label={`Criterion ${props.index + 1}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+
+            {props.isDeletable && (
+                <View style={styles.deleteButtonContainer}>
+                    <Button
+                        aria-label={`Delete criterion ${props.index + 1}`}
+                        color="destructive"
+                        disabled={!props.isDeletable}
+                        kind="tertiary"
+                        onClick={() => props.onDelete(props.index)}
+                        size="small"
+                        startIcon={trashIcon}
+                    >
+                        Delete
+                    </Button>
+                </View>
+            )}
+        </View>
+    );
+};
+
+const styles = StyleSheet.create({
+    questionContainer: {
+        display: "flex",
+        flexDirection: "column",
+        gap: spacing.xSmall_8,
+        paddingBottom: spacing.medium_16,
+    },
+    criteriaList: {
+        gap: spacing.small_12,
+    },
+    criterionContainer: {
+        paddingTop: spacing.xSmall_8,
+        paddingBottom: spacing.xSmall_8,
+        borderBottom: `1px solid ${semanticColor.border.primary}`,
+        ":last-child": {
+            borderBottom: "none",
+        },
+    },
+    deleteButtonContainer: {
+        display: "flex",
+        flexDirection: "row",
+        justifyContent: "flex-end",
+    },
+});
+
 export default FreeResponseEditor;
diff --git a/packages/perseus/src/widgets/free-response/free-response.testdata.ts b/packages/perseus/src/widgets/free-response/free-response.testdata.ts
index 3e65b8daee0..f4dd2172a0f 100644
--- a/packages/perseus/src/widgets/free-response/free-response.testdata.ts
+++ b/packages/perseus/src/widgets/free-response/free-response.testdata.ts
@@ -12,6 +12,11 @@ export function getFreeResponseItemData(): PerseusRenderer {
                 type: "free-response",
                 options: {
                     question: "test-question",
+                    scoringCriteria: [
+                        {
+                            text: "test-criterion",
+                        },
+                    ],
                 },
                 alignment: "default",
             },


Reviews:
user: login: rgpass
body: LGTM, just one idea.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T13:12:44Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T18:19:28Z
user: login: benchristel
body: Looks good overall! I left a few comments and questions inline. In short:

- the parser might need a default for the criteria - depends on whether production data exists for this widget yet.
- the editor could be made more accessible (and testable!)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T18:24:07Z
user: login: benchristel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T18:28:21Z
user: login: rgpass
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T15:12:30Z
user: login: rgpass
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T15:33:46Z
user: login: aag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T17:54:24Z
user: login: benchristel
body: LGTM!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T18:17:51Z
user: login: handeyeco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:29:56Z
user: login: rgpass
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:30:06Z
user: login: handeyeco
body: Ultra tiny, non-blocking feedback. LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:43:21Z
user: login: rgpass
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:45:03Z
user: login: rgpass
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T11:03:53Z

Review comments:
diff_hunk: @@ -32,18 +39,141 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
     render(): React.ReactNode {
+        const criteria = this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: rgpass
body: idea: Moving this to a new `renderCriteriaList()` function would help keep the `render` function small.
created_at: 2025-03-07T13:11:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +16,51 @@ const meta: Meta<typeof FreeResponseEditor> = {
 export default meta;
 type Story = StoryObj<typeof FreeResponseEditor>;
 
-export const Primary: Story = {
+export const NoCriteria: Story = {
     args: {},
 };
+
+export const OneCriterion: Story = {
+    args: {
+        scoringCriteria: [
+            {
+                text: "50 points for Gryffindor!",
+            },
+        ],
+    },
+};
+
+export const ThreeCriteria: Story = {
+    args: {
+        scoringCriteria: [
+            {
+                text: "50 points for Gryffindor!",
+            },
+            {
+                text: "50 points for Slytherin!",
+            },
+            {
+                text: "50 points for Hufflepuff!",
+            },
+        ],
+    },
+};
+
+const WithState = () => {
+    const [state, setState] = React.useState<
+        Partial<PropsFor<typeof FreeResponseEditor>>
+    >({
+        question: "What is is the truth?",
path: packages/perseus-editor/src/widgets/__stories__/free-response-editor.stories.tsx
user: login: handeyeco
body: ```suggestion
        question: "What is the truth?",
```
idk, maybe this was intentional?
created_at: 2025-03-10T16:10:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,6 +16,51 @@ const meta: Meta<typeof FreeResponseEditor> = {
 export default meta;
 type Story = StoryObj<typeof FreeResponseEditor>;
 
-export const Primary: Story = {
+export const NoCriteria: Story = {
     args: {},
 };
+
+export const OneCriterion: Story = {
+    args: {
+        scoringCriteria: [
+            {
+                text: "50 points for Gryffindor!",
+            },
+        ],
+    },
+};
+
+export const ThreeCriteria: Story = {
+    args: {
+        scoringCriteria: [
+            {
+                text: "50 points for Gryffindor!",
+            },
+            {
+                text: "50 points for Slytherin!",
+            },
+            {
+                text: "50 points for Hufflepuff!",
+            },
+        ],
+    },
+};
+
+const WithState = () => {
+    const [state, setState] = React.useState<
+        Partial<PropsFor<typeof FreeResponseEditor>>
+    >({
+        question: "What is is the truth?",
+        scoringCriteria: [{text: ""}],
+    });
+
+    const onChange = (options: Partial<PerseusFreeResponseWidgetOptions>) => {
path: packages/perseus-editor/src/widgets/__stories__/free-response-editor.stories.tsx
user: login: handeyeco
body: Does this mean that `state` should actually be `Partial<PerseusFreeResponseWidgetOptions>` instead of `Partial<PropsFor<typeof FreeResponseEditor>>`?
created_at: 2025-03-10T16:11:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,5 +9,10 @@ export const parseFreeResponseWidget: Parser<FreeResponseWidget> = parseWidget(
     constant("free-response"),
     object({
         question: string,
+        scoringCriteria: array(
+            object({
+                text: string,
+            }),
+        ),
path: packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.ts
user: login: benchristel
body: Have any free-response widgets been created in production? Any existing free-response widgets won't have `scoringCriteria`, so this parser will fail for them.

If you need to be compatible with existing data, I'd suggest defaulting to an empty array:

```ts
scoringCriteria: defaulted(
    array(
        object({
            text: string,
        }),
    ),
    () => [],
),
```

And you'd also want to add a regression test to `parse-perseus-json/regression-tests/item-data` to demonstrate the need for the default.
created_at: 2025-03-10T18:07:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,23 @@
+import type {PerseusFreeResponseWidgetOptions} from "@khanacademy/perseus-core";
+
+/**
+ * For details on the individual options, see the
+ * PerseusFreeResponseWidgetOptions type
path: packages/perseus-core/src/widgets/free-response/free-response-util.ts
user: login: benchristel
body: Thanks for adding this doc comment! To make it even more helpful, you could make the reference a link:

```suggestion
 * {@link PerseusFreeResponseWidgetOptions} type
```
created_at: 2025-03-10T18:09:01Z
author_association: MEMBER
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: handeyeco
body: I wonder if rather than using test IDs if we could use descriptive labels. "Criterion 1", "Delete Criterion 1", etc. We try our best to avoid test IDs when possible although we recognize that isn't always possible, just because if it's hard for us to find it with accessible names that means it's hard to find it with a11y tools.
created_at: 2025-03-10T18:16:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: handeyeco
body: I don't know which is preferred by designers: not showing the button when it can't be used or disabling the button when it can't be used. 🤔 
created_at: 2025-03-10T18:18:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: benchristel
body: To be accessible, this textarea should have a unique label, like "Text of Criterion 1". If it had such a label, we wouldn't need the test ID.
created_at: 2025-03-10T18:20:50Z
author_association: MEMBER
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
+                    kind="tertiary"
+                    onClick={() => props.onDelete(props.index)}
+                    size="small"
+                    startIcon={trashIcon}
+                >
+                    Delete
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: benchristel
body: To be accessible, this button's text should be unique, e.g. "Delete Criterion 1". It might also be okay to put the unique text in an aria-label and not have it visible... I'm not an accessibility expert.

If the buttons had unique labels, we wouldn't need testIds.
created_at: 2025-03-10T18:22:39Z
author_association: MEMBER
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: benchristel
body: Is there a way to allow deleting all criteria in the editor but block the item from being saved if the widget has no criteria? The "no criteria" state could be valuable as a transient state, e.g. if a content creator wants to remove and replace all criteria for a widget.

(as an aside, it irks me in general when tools prevent you from temporarily entering a state that isn't globally "valid". An example that's near and dear to many of us is how `go` won't compile your code if it has unused variables, so if you temporarily comment something out you often have to remove a bunch of variable declarations as well.)
created_at: 2025-03-10T18:28:21Z
author_association: MEMBER
diff_hunk: @@ -9,5 +9,10 @@ export const parseFreeResponseWidget: Parser<FreeResponseWidget> = parseWidget(
     constant("free-response"),
     object({
         question: string,
+        scoringCriteria: array(
+            object({
+                text: string,
+            }),
+        ),
path: packages/perseus-core/src/parse-perseus-json/perseus-parsers/free-response-widget.ts
user: login: rgpass
body: There are no FRW's (Free-Response Widget) in prod. The feature branch that we're merging into will be the introduction of this widget.
created_at: 2025-03-11T15:12:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: rgpass
body: > I don't know which is preferred by designers: not showing the button when it can't be used or disabling the button when it can't be used. 🤔

The Radio widget editor hides it. I'll change that.

> Is there a way to allow deleting all criteria in the editor but block the item from being saved if the widget has no criteria? The "no criteria" state could be valuable as a transient state, e.g. if a content creator wants to remove and replace all criteria for a widget.

I'm good with adding that functionality. Is there code I can emulate to do that?
created_at: 2025-03-11T15:33:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: aag
body: > The Radio widget editor hides it. I'll change that.

I think this is the wrong choice. It makes the software less predictable if actions appear and disappear as you use it. I think it's better for the user if buttons get disabled when they aren't currently available.

I'm open to the possibility of allowing deletion of the last item, but then not allowing saving until another item is added, though I do think it's more confusing. Personally, I would prefer the current experience, because the difference between clicking the "Delete" button and deleting the text out of the last box is not very large, and the current UI prevents the user from getting into an invalid state.

I think a bigger deal is that we allow deletion without confirmation or undo. You might spend several minutes writing a great criteria item, but then accidentally click the "Delete" button and delete it with no way to get it back. The easiest solution is to add a confirmation step (either inline or through a `prompt()` modal).
created_at: 2025-03-11T17:54:24Z
author_association: MEMBER
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: handeyeco
body: > The easiest solution is to add a confirmation step

Not a designer and I'm confident our designer wouldn't approve of me making design suggestions, but I think this would be pretty easy since you have a component per criteria.

``` TSX
function MyComponent({ handleDelete }) {
  const [confirmDelete, setConfirmDelete] = useState<boolean>(false)

  function handleDeleteClick() {
    if (confirmDelete) {
      handleDelete()
      // setConfirmDelete(false) probably don't need this since it's deleted
    } else {
      setConfirmDelete(true)
    }
  }

  return (
    <button onClick={handleDeleteClick}>{confirmDelete ? "Are you sure?" : "Delete"}</button>
  )
}
```

But yeah, overall go with your gut. It's an internal tool and the users of it have a direct line to us if they don't like something.
created_at: 2025-03-11T19:29:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: rgpass
body: Do we typically consult a designer for widget editors? If not, how do we proceed?
created_at: 2025-03-11T19:30:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,148 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: handeyeco
body: I get nervous when I see [key set to index](https://react.dev/learn/rendering-lists#why-does-react-need-keys), but I imagine this might be unavoidable because in the editor maybe we can't use `criterion.text`? Because (1) it might be an empty string and (2) we might have multiple empty strings until we fill out the form?
created_at: 2025-03-11T19:36:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,148 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: handeyeco
body: Ultra tiny nit but I feel like telling CriterionEditor how many criteria there are is TMI. Really what you're saying is something along the lines of `deletable`.

```suggestion
                    deletable={this.props.scoringCriteria.length <= 1}
```

Go with your gut on this one; feel free to ignore.
created_at: 2025-03-11T19:41:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,148 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: rgpass
body: That's right.
created_at: 2025-03-12T10:45:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,18 +39,145 @@ class FreeResponseEditor extends React.Component<Props> {
         return warnings;
     }
 
+    handleUpdateCriterion = (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => {
+        const newCriteria = this.props.scoringCriteria.map((c, i) => {
+            if (i === index) {
+                return criterion;
+            }
+            return c;
+        });
+
+        this.props.onChange({
+            scoringCriteria: newCriteria,
+        });
+    };
+
+    handleDeleteCriterion = (index: number) => {
+        this.props.onChange({
+            scoringCriteria: this.props.scoringCriteria.filter(
+                (_, i) => i !== index,
+            ),
+        });
+    };
+
+    handleAddCriterion = () => {
+        this.props.onChange({
+            scoringCriteria: [...this.props.scoringCriteria, {text: ""}],
+        });
+    };
+
+    renderCriteriaList = () => {
+        return this.props.scoringCriteria.map((criterion, index) => {
+            return (
+                <CriterionEditor
+                    criterion={criterion}
+                    index={index}
+                    key={index}
+                    numCriteria={this.props.scoringCriteria.length}
+                    onChange={this.handleUpdateCriterion}
+                    onDelete={this.handleDeleteCriterion}
+                />
+            );
+        });
+    };
+
     render(): React.ReactNode {
         return (
-            <LabeledTextField
-                label={"Question"}
-                value={this.props.question}
-                onChange={(question: string) => this.props.onChange({question})}
-                style={{
-                    marginBottom: spacing.large_24,
-                }}
-            />
+            <View>
+                <label className={css(styles.questionContainer)}>
+                    <HeadingSmall>Question</HeadingSmall>
+                    <textarea
+                        value={this.props.question}
+                        onChange={(e) =>
+                            this.props.onChange({question: e.target.value})
+                        }
+                    />
+                </label>
+                <View>
+                    <HeadingSmall>Scoring criteria</HeadingSmall>
+                    <View style={styles.criteriaList}>
+                        {this.renderCriteriaList()}
+                    </View>
+                    <View>
+                        <Button
+                            onClick={this.handleAddCriterion}
+                            startIcon={plusCircleIcon}
+                        >
+                            Add an item
+                        </Button>
+                    </View>
+                </View>
+            </View>
         );
     }
 }
 
+const CriterionEditor = function (props: {
+    index: number;
+    numCriteria: number;
+    criterion: PerseusFreeResponseWidgetScoringCriterion;
+    onChange: (
+        index: number,
+        criterion: PerseusFreeResponseWidgetScoringCriterion,
+    ) => void;
+    onDelete: (index: number) => void;
+}): React.ReactNode {
+    const isOnlyCriterion = props.numCriteria <= 1;
+
+    return (
+        <View style={styles.criterionContainer}>
+            <textarea
+                data-testid={`criterion-input-${props.index}`}
+                onChange={(e) =>
+                    props.onChange(props.index, {
+                        text: e.target.value,
+                    })
+                }
+                value={props.criterion.text}
+            />
+            <View style={styles.deleteButtonContainer}>
+                <Button
+                    color="destructive"
+                    data-testid={`criterion-delete-button-${props.index}`}
+                    disabled={isOnlyCriterion}
path: packages/perseus-editor/src/widgets/free-response-editor.tsx
user: login: rgpass
body: > It's an internal tool and the users of it have a direct line to us if they don't like something.

Given this and that we have time before this rolls out, I'm going to merge this as-is. It's a simple enough change to make later.
created_at: 2025-03-12T11:03:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-06T21:25:11Z
body: **Size Change:** +1.02 kB (+0.12%) 

**Total Size:** 874 kB

| Filename | Size | Change |
| :--- | :---: | :---: |
| `packages/perseus-core/dist/es/index.js` | 30.2 kB | +267 B (+0.89%) |
| `packages/perseus-editor/dist/es/index.js` | 277 kB | +756 B (+0.27%) |

<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `packages/kas/dist/es/index.js` | 39.7 kB |
| `packages/keypad-context/dist/es/index.js` | 760 B |
| `packages/kmath/dist/es/index.js` | 11.1 kB |
| `packages/math-input/dist/es/index.js` | 78.2 kB |
| `packages/math-input/dist/es/strings.js` | 1.79 kB |
| `packages/perseus-linter/dist/es/index.js` | 22.8 kB |
| `packages/perseus-score/dist/es/index.js` | 20.6 kB |
| `packages/perseus/dist/es/index.js` | 368 kB |
| `packages/perseus/dist/es/strings.js` | 6.74 kB |
| `packages/pure-markdown/dist/es/index.js` | 4.14 kB |
| `packages/simple-markdown/dist/es/index.js` | 13.1 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-06T21:49:39Z
body: # npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (2020640f) and published it to npm. You
can install it using the tag `PR2286`.

Example:
```sh
pnpm add @khanacademy/perseus@PR2286
```

If you are working in Khan Academy's webapp, you can run:
```sh
./dev/tools/bump_perseus_version.sh -t PR2286
```
author_association: CONTRIBUTOR