From c45785cd0d9253c713d2096bc29beef15a393ba4 Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 8 Apr 2026 10:58:26 -0500 Subject: [PATCH 01/30] [LEMS-3958/planning-not-scored] Add planning document for unscored IG --- .../__docs__/notes/not-graded.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md diff --git a/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md b/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md new file mode 100644 index 00000000000..eef12ed5a56 --- /dev/null +++ b/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md @@ -0,0 +1,142 @@ +# Not-Graded InteractiveGraph Feature + +## Goal + +Allow content creators to mark an InteractiveGraph (IG) widget as unscored, turning it into a "sketchpad" learners can use to work through a problem visually alongside other, scored parts of an exercise. + +## Background + +Perseus already has a `graded` field on `WidgetOptions` (in `packages/perseus-core/src/data-schema.ts`). Historically it was only relevant for IFrame widgets and a few others that are never scored (Explanation, Definition, Image). The scoring system in `packages/perseus-score` already reads `widget.graded` and skips unscored widgets — so the scoring pipeline doesn't need to change. + +What's missing is the authoring experience (a toggle in the editor), the learner experience (a visual "not graded" indicator), and the review-mode behavior (suppress the correct-answer reveal when `graded: false`). Because other widgets may eventually benefit from this feature, the UI plumbing should live in shared code (`WidgetEditor`, `WidgetContainer`, `Renderer`) rather than inside the interactive graph widget directly. + +### Why not add a flag to `PerseusInteractiveGraphWidgetOptions`? + +Putting `graded` on the per-widget options type would require every consumer to handle it individually. `WidgetOptions.graded` is already the right home — it's shared across all widget types — so we're building on that. + +--- + +## Objectives + +1. **Editor toggle** — Content creators get a "Graded" switch in the IG widget editor, defaulting to on (graded). The switch is only shown for widgets that opt in. +2. **Hide answer fields when ungraded** — When `graded` is off, the answer-configuration sections in the IG editor are hidden. The stored answer data is preserved so toggling back on restores it. +3. **Learner indicator** — When `graded: false` is rendered for a learner, a visible label makes it clear the widget won't be scored. +4. **Scoring is unaffected** — Already handled: `is-widget-scoreable.ts` returns `false` when `graded === false`. +5. **Review mode is unaffected** — After a learner submits, a `graded: false` IG should stay in the learner's last state and never reveal the correct answer. + +--- + +## Current State + +| Area | Status | Notes | +|------|--------|-------| +| `WidgetOptions.graded` field | ✅ Done | Defined in `data-schema.ts` line 382 | +| Scoring respects `graded` | ✅ Done | `is-widget-scoreable.ts` already skips `graded: false` widgets | +| `widget-editor.tsx` serializes `graded` | ✅ Done | `serialize()` includes `graded: widgetInfo.graded` | +| `renderer.tsx` sets `graded: true` as default | ✅ Done | Line 427 in the default widget info object | +| `data-schema.ts` JSDoc | ⚠️ Needs update | Comment currently says "except for IFrame widgets (deprecated)" — should describe the new use case | +| `graded` in `UniversalWidgetProps` | ❌ Missing | `types.ts` has `static` but not `graded` | +| `renderer.tsx` passes `graded` to widgets | ❌ Missing | `getWidgetProps()` passes `static` (line 550) but not `graded` | +| `supportsGradedToggle` registry function | ❌ Missing | No analog to `supportsStaticMode` exists yet | +| "Graded" toggle in `widget-editor.tsx` | ❌ Missing | Has "Static" toggle but no "Graded" toggle | +| "Not graded" visual indicator | ❌ Missing | `widget-container.tsx` has no graded-aware rendering | +| IG editor hides answer fields when ungraded | ❌ Missing | `interactive-graph-editor.tsx` always renders answer sections | +| IG review-mode respects `graded: false` | ❌ Missing | `stateful-mafs-graph.tsx` doesn't check `graded` | + +--- + +## Implementation Plan + +### 1. Update `data-schema.ts` JSDoc + +In `packages/perseus-core/src/data-schema.ts`, update the comment on `WidgetOptions.graded` to describe the expanded use case (unscored "sketchpad" mode for IG, and potentially other widgets in the future). + +--- + +### 2. Add `graded` to `UniversalWidgetProps` + +In `packages/perseus/src/types.ts`, add `graded?: boolean | null` to `UniversalWidgetProps`, mirroring how `static` is declared. This lets widgets receive and act on the flag. + +--- + +### 3. Pass `graded` through `Renderer.getWidgetProps()` + +In `packages/perseus/src/renderer.tsx`, in the `getWidgetProps()` method, add `graded: widgetInfo?.graded` alongside the existing `static: widgetInfo?.static` (line 550). This routes the flag down to individual widgets. + +--- + +### 4. Add `supportsGradedToggle` to the widget registry + +In `packages/perseus/src/widgets.ts`, add a `supportsGradedToggle()` function. Unlike `supportsStaticMode()` — which infers support by checking whether `getCorrectUserInput` is exported — this should read an explicit boolean flag from the widget's export object (e.g., `widgetExport.supportsGradedToggle`). Add `supportsGradedToggle: true` to the interactive-graph widget export in `packages/perseus/src/widgets/interactive-graphs/index.ts` (or wherever IG registers itself). + +> **Why explicit rather than inferred?** `supportsStaticMode` uses `getCorrectUserInput` as a proxy because static mode is tightly coupled to the concept of a correct answer. Graded/ungraded is a distinct concern — a widget might want to support the ungraded toggle even if it doesn't have a classic "correct answer" concept. + +--- + +### 5. Add "Graded" toggle to `WidgetEditor` + +In `packages/perseus-editor/src/components/widget-editor.tsx`: + +- Add a `_setGraded` handler method, mirroring `_setStatic`. +- In `render()`, call `Widgets.supportsGradedToggle(widgetInfo.type)` (analogous to the `supportsStaticMode` check on line 155). +- Render a `LabeledSwitch` labeled "Graded" when that returns true. The switch should default to `checked={widgetInfo.graded !== false}` (treats `undefined` and `true` as graded). + +--- + +### 6. Add "Not graded" indicator in `WidgetContainer` + +In `packages/perseus/src/widget-container.tsx`, when `props.graded === false`, render a visible label (e.g., "This graph is for your use only and will not be graded.") near the widget. Placing this in `WidgetContainer` rather than inside the IG widget makes it available to any future widget that gains the toggle. + +--- + +### 7. Hide answer sections in the IG editor when ungraded + +In `packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx`: + +- Accept a `graded?: boolean` prop (passed through from `WidgetEditor`, which already spreads `widgetInfo.options` — note: `graded` is on `widgetInfo` itself, not `widgetInfo.options`, so `WidgetEditor` needs to pass it explicitly). +- Wrap `InteractiveGraphCorrectAnswer`, `AngleAnswerOptions`, `GraphPointsCountSelector`, `PolygonAnswerOptions`, `SegmentCountSelector`, and `StartCoordsSettings` with `{(graded ?? true) && ...}`. +- Do **not** clear the stored `correct` data — just hide the UI. This ensures the correct answer is restored if the content creator re-enables grading. + +--- + +### 8. Suppress correct-answer reveal in review mode + +In `packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx`, the current static-mode guard (line 138) renders the correct answer when `props.static && props.correct`. Add a `graded?: boolean` prop and change the condition to: + +```typescript +if (props.static && props.correct && props.graded !== false) { +``` + +This ensures a `graded: false` IG is never replaced by the correct answer, even if it's in static mode. The widget stays in whatever state the learner left it. + +`interactive-graph.tsx` will need to receive `graded` from `UniversalWidgetProps` (step 2) and forward it to `StatefulMafsGraph`. + +--- + +### 9. Tests and stories + +Each change above needs coverage: + +- **`widget-editor.tsx`**: Unit test that the "Graded" toggle renders for IG and not for e.g. radio; test that toggling calls the handler. +- **`interactive-graph-editor.tsx`**: Test that answer sections are hidden when `graded={false}`. +- **`widget-container.tsx`**: Test that the "not graded" message renders when `graded={false}` and not otherwise. +- **`stateful-mafs-graph.tsx`**: Test that setting `static={true}` on a `graded={false}` graph does not display the correct answer. +- **Storybook**: Add a story for the IG widget with `graded={false}` in both learner and review contexts. + +--- + +## Files to Change + +| File | Change | +|------|--------| +| `packages/perseus-core/src/data-schema.ts` | Update JSDoc on `WidgetOptions.graded` | +| `packages/perseus/src/types.ts` | Add `graded?: boolean \| null` to `UniversalWidgetProps` | +| `packages/perseus/src/renderer.tsx` | Pass `graded: widgetInfo?.graded` in `getWidgetProps()` | +| `packages/perseus/src/widgets.ts` | Add `supportsGradedToggle()` function | +| `packages/perseus/src/widgets/interactive-graphs/index.ts` (or export file) | Add `supportsGradedToggle: true` to IG widget export | +| `packages/perseus/src/widget-container.tsx` | Render "not graded" message when `graded === false` | +| `packages/perseus-editor/src/components/widget-editor.tsx` | Add `_setGraded` handler + `LabeledSwitch` for "Graded" | +| `packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx` | Accept `graded?: boolean`; wrap answer sections | +| `packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx` | Forward `graded` to `StatefulMafsGraph` | +| `packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx` | Guard static/correct-answer reveal with `graded !== false` | +| Tests + stories | Cover all of the above | From c0f0875db98595e147beae37ea97316264a4a69c Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 8 Apr 2026 12:41:45 -0500 Subject: [PATCH 02/30] [LEMS-3958/planning-not-scored] docs(changeset): --- .changeset/famous-ghosts-type.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/famous-ghosts-type.md diff --git a/.changeset/famous-ghosts-type.md b/.changeset/famous-ghosts-type.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/famous-ghosts-type.md @@ -0,0 +1,2 @@ +--- +--- From 17d86eca78c1f64212b79ede635a39df573eaefe Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 8 Apr 2026 14:54:16 -0500 Subject: [PATCH 03/30] [LEMS-3958/planning-not-scored] update requirements --- .../__docs__/notes/not-graded.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md b/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md index eef12ed5a56..494e8b27175 100644 --- a/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md +++ b/packages/perseus/src/widgets/interactive-graphs/__docs__/notes/not-graded.md @@ -8,7 +8,7 @@ Allow content creators to mark an InteractiveGraph (IG) widget as unscored, turn Perseus already has a `graded` field on `WidgetOptions` (in `packages/perseus-core/src/data-schema.ts`). Historically it was only relevant for IFrame widgets and a few others that are never scored (Explanation, Definition, Image). The scoring system in `packages/perseus-score` already reads `widget.graded` and skips unscored widgets — so the scoring pipeline doesn't need to change. -What's missing is the authoring experience (a toggle in the editor), the learner experience (a visual "not graded" indicator), and the review-mode behavior (suppress the correct-answer reveal when `graded: false`). Because other widgets may eventually benefit from this feature, the UI plumbing should live in shared code (`WidgetEditor`, `WidgetContainer`, `Renderer`) rather than inside the interactive graph widget directly. +What's missing is the authoring experience (a toggle in the editor), the learner experience (a visual "not graded" indicator), and the review-mode behavior (suppress the correct-answer reveal when `graded: false`). The editor toggle and renderer plumbing should live in shared code (`WidgetEditor`, `Renderer`) so other widgets can adopt them later. The learner-facing indicator, however, belongs inside the IG widget itself — not in `WidgetContainer` — because `WidgetContainer` can't distinguish between widgets that actively opted into the ungraded state versus widgets like Explanation and Image that are always unscored and should show no such label. ### Why not add a flag to `PerseusInteractiveGraphWidgetOptions`? @@ -20,7 +20,7 @@ Putting `graded` on the per-widget options type would require every consumer to 1. **Editor toggle** — Content creators get a "Graded" switch in the IG widget editor, defaulting to on (graded). The switch is only shown for widgets that opt in. 2. **Hide answer fields when ungraded** — When `graded` is off, the answer-configuration sections in the IG editor are hidden. The stored answer data is preserved so toggling back on restores it. -3. **Learner indicator** — When `graded: false` is rendered for a learner, a visible label makes it clear the widget won't be scored. +3. **Learner indicator** — When `graded: false` is rendered for a learner, a visible label inside the IG widget makes it clear the widget won't be scored. This lives in the widget rather than `WidgetContainer` because many existing widgets (Explanation, Image, etc.) are already `graded: false` but should never show such a label. 4. **Scoring is unaffected** — Already handled: `is-widget-scoreable.ts` returns `false` when `graded === false`. 5. **Review mode is unaffected** — After a learner submits, a `graded: false` IG should stay in the learner's last state and never reveal the correct answer. @@ -39,7 +39,7 @@ Putting `graded` on the per-widget options type would require every consumer to | `renderer.tsx` passes `graded` to widgets | ❌ Missing | `getWidgetProps()` passes `static` (line 550) but not `graded` | | `supportsGradedToggle` registry function | ❌ Missing | No analog to `supportsStaticMode` exists yet | | "Graded" toggle in `widget-editor.tsx` | ❌ Missing | Has "Static" toggle but no "Graded" toggle | -| "Not graded" visual indicator | ❌ Missing | `widget-container.tsx` has no graded-aware rendering | +| "Not graded" visual indicator | ❌ Missing | `interactive-graph.tsx` doesn't render an ungraded label | | IG editor hides answer fields when ungraded | ❌ Missing | `interactive-graph-editor.tsx` always renders answer sections | | IG review-mode respects `graded: false` | ❌ Missing | `stateful-mafs-graph.tsx` doesn't check `graded` | @@ -83,9 +83,9 @@ In `packages/perseus-editor/src/components/widget-editor.tsx`: --- -### 6. Add "Not graded" indicator in `WidgetContainer` +### 6. Add "Not graded" indicator inside the IG widget -In `packages/perseus/src/widget-container.tsx`, when `props.graded === false`, render a visible label (e.g., "This graph is for your use only and will not be graded.") near the widget. Placing this in `WidgetContainer` rather than inside the IG widget makes it available to any future widget that gains the toggle. +In `packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx`, when `props.graded === false`, render a visible label (e.g., "This graph is for your use only and will not be graded.") alongside the graph. Placing this inside the IG widget rather than in `WidgetContainer` is intentional: `WidgetContainer` can't distinguish between widgets that actively opted into the ungraded state (IG) and widgets that are always unscored by convention (Explanation, Image, Definition). Those existing widgets already have `graded: false` in content and must not grow a new label. --- @@ -119,7 +119,7 @@ Each change above needs coverage: - **`widget-editor.tsx`**: Unit test that the "Graded" toggle renders for IG and not for e.g. radio; test that toggling calls the handler. - **`interactive-graph-editor.tsx`**: Test that answer sections are hidden when `graded={false}`. -- **`widget-container.tsx`**: Test that the "not graded" message renders when `graded={false}` and not otherwise. +- **`interactive-graph.tsx`**: Test that the "not graded" message renders when `graded={false}` and not otherwise. - **`stateful-mafs-graph.tsx`**: Test that setting `static={true}` on a `graded={false}` graph does not display the correct answer. - **Storybook**: Add a story for the IG widget with `graded={false}` in both learner and review contexts. @@ -134,9 +134,8 @@ Each change above needs coverage: | `packages/perseus/src/renderer.tsx` | Pass `graded: widgetInfo?.graded` in `getWidgetProps()` | | `packages/perseus/src/widgets.ts` | Add `supportsGradedToggle()` function | | `packages/perseus/src/widgets/interactive-graphs/index.ts` (or export file) | Add `supportsGradedToggle: true` to IG widget export | -| `packages/perseus/src/widget-container.tsx` | Render "not graded" message when `graded === false` | | `packages/perseus-editor/src/components/widget-editor.tsx` | Add `_setGraded` handler + `LabeledSwitch` for "Graded" | | `packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx` | Accept `graded?: boolean`; wrap answer sections | -| `packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx` | Forward `graded` to `StatefulMafsGraph` | +| `packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx` | Render "not graded" label when `graded === false`; forward `graded` to `StatefulMafsGraph` | | `packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx` | Guard static/correct-answer reveal with `graded !== false` | | Tests + stories | Cover all of the above | From ae925c0387b6414a0098b96fa44c64f9b77aa3aa Mon Sep 17 00:00:00 2001 From: Matthew Curtis Date: Wed, 8 Apr 2026 15:41:59 -0500 Subject: [PATCH 04/30] [LEMS-3958/implementation-experiment] Experiment with Claude implementation --- packages/perseus-core/src/data-schema.ts | 8 +- .../src/components/widget-editor.tsx | 20 ++++ .../interactive-graph-editor.test.tsx | 51 +++++++++ .../interactive-graph-editor.tsx | 104 ++++++++++-------- .../src/mixins/widget-prop-denylist.ts | 2 + packages/perseus/src/renderer.tsx | 1 + packages/perseus/src/types.ts | 3 + packages/perseus/src/widgets.ts | 9 ++ .../interactive-graph.test.tsx | 41 ++++++- .../interactive-graphs/interactive-graph.tsx | 29 +++-- .../stateful-mafs-graph.test.tsx | 42 +++++++ .../stateful-mafs-graph.tsx | 9 +- 12 files changed, 257 insertions(+), 62 deletions(-) diff --git a/packages/perseus-core/src/data-schema.ts b/packages/perseus-core/src/data-schema.ts index a5f666fb676..10c9cd3850e 100644 --- a/packages/perseus-core/src/data-schema.ts +++ b/packages/perseus-core/src/data-schema.ts @@ -376,8 +376,14 @@ export type WidgetOptions< */ static?: boolean; /** - * Whether a widget is scored. Usually true except for IFrame widgets (deprecated). + * Whether a widget is scored. * Default: true + * + * When false, the widget acts as an unscored "sketchpad" that learners can + * use to work through a problem visually alongside other, scored parts of + * an exercise. The scoring pipeline already skips widgets with graded:false. + * Widgets that want to surface this state to learners (e.g. InteractiveGraph) + * should render a visible indicator inside the widget itself. */ graded?: boolean; /** diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index 3d92c58c317..99ee2002b0e 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -106,6 +106,14 @@ class WidgetEditor extends React.Component< this.props.onChange(newWidgetInfo); }; + _setGraded = (value: boolean) => { + const newWidgetInfo = { + ...this.state.widgetInfo, + graded: value, + } satisfies PerseusWidget; + this.props.onChange(newWidgetInfo); + }; + _handleAlignmentChange = (e: React.SyntheticEvent) => { const newAlignment = e.currentTarget.value as Alignment; const newWidgetInfo = Object.assign( @@ -153,6 +161,9 @@ class WidgetEditor extends React.Component< } const supportsStaticMode = Widgets.supportsStaticMode(widgetInfo.type); + const supportsGradedToggle = Widgets.supportsGradedToggle( + widgetInfo.type, + ); return (
@@ -187,6 +198,14 @@ class WidgetEditor extends React.Component< onChange={this._setStatic} /> )} + {supportsGradedToggle && ( + + )} {supportedAlignments.length > 1 && ( diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx index 45931f58d17..ccad21f6152 100644 --- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx @@ -815,6 +815,57 @@ describe("InteractiveGraphEditor", () => { expect(screen.getByRole("option", {name: "None"})).toBeInTheDocument(); }); + test("hides answer sections when graded is false", async () => { + // Arrange, Act + render( + , + {wrapper: RenderStateRoot}, + ); + + // Assert: "Correct Answer" section is hidden when ungraded + await waitFor(() => + expect( + screen.queryByText("Correct Answer"), + ).not.toBeInTheDocument(), + ); + }); + + test("shows answer sections when graded is true", async () => { + // Arrange, Act + render( + , + {wrapper: RenderStateRoot}, + ); + + // Assert: "Correct Answer" section is visible when graded + expect(await screen.findByText("Correct Answer")).toBeInTheDocument(); + }); + + test("shows answer sections when graded is undefined (default behavior)", async () => { + // Arrange, Act + render( + , + {wrapper: RenderStateRoot}, + ); + + // Assert: "Correct Answer" section is visible by default + expect(await screen.findByText("Correct Answer")).toBeInTheDocument(); + }); + test("fixes a `correct` prop with the wrong graph type, defaulting it to `graph`", () => { // This behavior is a workaround for the AX editor, which passes // answerless data to the editor in question stems. The value of diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx index e8e8a9a66c8..afe950dcac0 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx @@ -145,6 +145,12 @@ export type Props = { // Graphs in static mode are not interactive, and their coords are // set to those of the "correct" graph in the editor. static?: boolean; + /** + * Whether this widget is graded. When false, the answer-configuration + * sections are hidden (but the stored answer data is preserved so toggling + * back on restores it). + */ + graded?: boolean; }; // JSDoc will be shown in Storybook widget editor description @@ -410,54 +416,58 @@ class InteractiveGraphEditor extends React.Component { } onChange={this.props.onChange} /> - - {graph} - - - {this.props.correct?.type === "angle" && ( - - )} - {this.props.correct?.type === "point" && ( - + {(this.props.graded ?? true) && ( + <> + + {graph} + + + {this.props.correct?.type === "angle" && ( + + )} + {this.props.correct?.type === "point" && ( + + )} + {this.props.correct?.type === "polygon" && ( + + )} + {this.props.correct?.type === "segment" && ( + + )} + + {this.props.graph?.type && + shouldShowStartCoordsUI( + this.props.graph, + this.props.static, + ) && ( + + )} + )} - {this.props.correct?.type === "polygon" && ( - - )} - {this.props.correct?.type === "segment" && ( - - )} - - {this.props.graph?.type && - shouldShowStartCoordsUI( - this.props.graph, - this.props.static, - ) && ( - - )} ) { diff --git a/packages/perseus/src/renderer.tsx b/packages/perseus/src/renderer.tsx index 069044f0e88..73e7cecca6f 100644 --- a/packages/perseus/src/renderer.tsx +++ b/packages/perseus/src/renderer.tsx @@ -548,6 +548,7 @@ class Renderer widgetIndex: this._getWidgetIndexById(widgetId), alignment: widgetInfo && widgetInfo.alignment, static: widgetInfo?.static, + graded: widgetInfo?.graded, problemNum: this.props.problemNum, apiOptions: this.getApiOptions(), keypadElement: this.props.keypadElement, diff --git a/packages/perseus/src/types.ts b/packages/perseus/src/types.ts index 6310194ed63..d537af04973 100644 --- a/packages/perseus/src/types.ts +++ b/packages/perseus/src/types.ts @@ -489,6 +489,8 @@ export type WidgetExports< version?: Version; isLintable?: boolean; tracking?: Tracking; + /** When true, the widget editor shows a "Graded" toggle. */ + supportsGradedToggle?: boolean; getOneCorrectAnswerFromRubric?: ( rubric: WidgetOptions, @@ -546,6 +548,7 @@ type UniversalWidgetProps = { widgetIndex: number; alignment: string | null | undefined; static: boolean | null | undefined; + graded?: boolean | null; problemNum: number | null | undefined; apiOptions: APIOptionsWithDefaults; keypadElement?: any; diff --git a/packages/perseus/src/widgets.ts b/packages/perseus/src/widgets.ts index fa7bba2ae48..844185a3191 100644 --- a/packages/perseus/src/widgets.ts +++ b/packages/perseus/src/widgets.ts @@ -182,6 +182,15 @@ export const supportsStaticMode = (type: string): boolean | undefined => { return widgetInfo && widgetInfo.getCorrectUserInput != null; }; +/** + * Returns true if the widget supports the "Graded" toggle in the editor. + * A widget opts in by setting supportsGradedToggle: true in its export object. + */ +export const supportsGradedToggle = (type: string): boolean => { + const widgetInfo = widgets.get(type); + return widgetInfo?.supportsGradedToggle === true; +}; + /** * Returns the tracking option for the widget. The default is "", * which means simply to track interactions once. The other available diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx index aa7bc118f36..a0b7586c960 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.test.tsx @@ -6,7 +6,7 @@ import { getDefaultFigureForType, } from "@khanacademy/perseus-core"; import {semanticColor} from "@khanacademy/wonder-blocks-tokens"; -import {waitFor} from "@testing-library/react"; +import {screen, waitFor} from "@testing-library/react"; import {userEvent as userEventLib} from "@testing-library/user-event"; import {Plot} from "mafs"; import * as React from "react"; @@ -1933,3 +1933,42 @@ describe("Interactive Graph", function () { ); }); }); + +describe("ungraded interactive graph", () => { + beforeEach(() => { + jest.spyOn(Dependencies, "getDependencies").mockReturnValue( + testDependencies, + ); + }); + + it("renders a 'not graded' message when graded is false", () => { + // Arrange, Act + const question = interactiveGraphQuestionBuilder() + .withSegments({numSegments: 1}) + .build(); + question.widgets["interactive-graph 1"].graded = false; + renderQuestion(question, blankOptions); + + // Assert + expect( + screen.getByText( + "This graph is for your use only and will not be graded.", + ), + ).toBeInTheDocument(); + }); + + it("does not render a 'not graded' message when graded is true", () => { + // Arrange, Act + const question = interactiveGraphQuestionBuilder() + .withSegments({numSegments: 1}) + .build(); + renderQuestion(question, blankOptions); + + // Assert + expect( + screen.queryByText( + "This graph is for your use only and will not be graded.", + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx index d74e592cbcd..8c5c8bc647a 100644 --- a/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/interactive-graph.tsx @@ -301,16 +301,24 @@ class InteractiveGraph extends React.Component { }; return ( - + <> + {this.props.graded === false && ( +

+ This graph is for your use only and will not be graded. +

+ )} + + ); } @@ -938,4 +946,5 @@ export default { getStartUserInput, getCorrectUserInput, getUserInputFromSerializedState, + supportsGradedToggle: true, } satisfies WidgetExports; diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx index 8e0505ce249..21086cd4136 100644 --- a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.test.tsx @@ -142,4 +142,46 @@ describe("StatefulMafsGraph", () => { // means we are still rendering only 3 sides. expect(screen.getAllByTestId("movable-point").length).toBe(4); }); + + it("displays the correct answer (angle) when static is true and graded is true", () => { + // Arrange: learner state is a segment (2 movable points), correct answer + // is an angle (3 movable points). When graded, static mode should show + // the correct answer. + const correctAngle = {type: "angle" as const}; + + // Act + render( + , + ); + + // Assert: angle has 3 movable points (vertex + 2 endpoints) + expect(screen.getAllByTestId("movable-point").length).toBe(3); + }); + + it("keeps the learner state (segment) when static is true but graded is false", () => { + // Arrange: learner state is a segment (2 movable points), correct answer + // is an angle (3 movable points). When ungraded, static mode should NOT + // replace the learner's state with the correct answer. + const correctAngle = {type: "angle" as const}; + + // Act + render( + , + ); + + // Assert: segment has 2 movable points (the learner's state is preserved) + expect(screen.getAllByTestId("movable-point").length).toBe(2); + }); }); diff --git a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx index b248a9b55fc..f30aefa7059 100644 --- a/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx +++ b/packages/perseus/src/widgets/interactive-graphs/stateful-mafs-graph.tsx @@ -46,6 +46,7 @@ export type StatefulMafsGraphProps = { static: InteractiveGraphProps["static"]; showAxisArrows: InteractiveGraphProps["showAxisArrows"]; widgetId: string; + graded?: boolean | null; }; export type StatefulMafsGraphType = { @@ -133,9 +134,11 @@ export const StatefulMafsGraph = React.forwardRef< allowReflexAngles, ]); - // If the graph is static, it always displays the correct answer. This is - // standard behavior for Perseus widgets (e.g. compare the Radio widget). - if (props.static && props.correct) { + // If the graph is static and graded, it always displays the correct answer. + // This is standard behavior for Perseus widgets (e.g. compare the Radio + // widget). When graded is false the widget is a sketchpad and should never + // reveal the correct answer, even in static/review mode. + if (props.static && props.correct && props.graded !== false) { return ( Date: Tue, 14 Apr 2026 15:44:05 -0700 Subject: [PATCH 05/30] [LEMS-3958/implementation-experiment] added widget editor settings to improve content editor ux --- .../__tests__/widget-editor.test.tsx | 71 ++++-------- .../src/components/alignment-select.tsx | 71 +++++++++--- .../src/components/widget-editor-settings.tsx | 104 ++++++++++++++++++ .../src/components/widget-editor.tsx | 68 ++++-------- .../src/styles/perseus-editor.css | 5 +- .../interactive-graph-editor.test.tsx | 4 +- .../interactive-graph-editor.tsx | 6 +- .../locked-figures/labeled-row.tsx | 21 +++- .../src/widgets/radio/editor.tsx | 11 +- 9 files changed, 231 insertions(+), 130 deletions(-) create mode 100644 packages/perseus-editor/src/components/widget-editor-settings.tsx diff --git a/packages/perseus-editor/src/components/__tests__/widget-editor.test.tsx b/packages/perseus-editor/src/components/__tests__/widget-editor.test.tsx index a1fd860e2c1..bf822c725b3 100644 --- a/packages/perseus-editor/src/components/__tests__/widget-editor.test.tsx +++ b/packages/perseus-editor/src/components/__tests__/widget-editor.test.tsx @@ -8,7 +8,6 @@ import {testDependencies} from "../../testing/test-dependencies"; import {registerAllWidgetsAndEditorsForTesting} from "../../util/register-all-widgets-and-editors-for-testing"; import WidgetEditor from "../widget-editor"; -import type {Alignment} from "@khanacademy/perseus-core"; import type {UserEvent} from "@testing-library/user-event"; describe("WidgetEditor", () => { @@ -37,7 +36,7 @@ describe("WidgetEditor", () => { "getSupportedAlignments", ).mockReturnValue(["block", "inline", "full-width"]); - const {container} = render( + render( { // Assert // Even though getSupportedAlignments returns multiple alignments, // the dropdown should NOT be shown because showAlignmentOptions is false - const alignmentDropdown = - // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access - container.querySelector("select.alignment"); - expect(alignmentDropdown).not.toBeInTheDocument(); + expect( + screen.queryByRole("combobox", {name: "Alignment"}), + ).not.toBeInTheDocument(); }); it("should display alignment dropdown when widget supports multiple alignments", () => { @@ -91,9 +89,10 @@ describe("WidgetEditor", () => { ); // Assert - const dropdown = screen.getByRole("combobox"); + const dropdown = screen.getByRole("combobox", { + name: "Alignment", + }); expect(dropdown).toBeInTheDocument(); - expect(dropdown).toHaveClass("alignment"); }); it("should NOT display alignment dropdown when widget supports only one alignment", () => { @@ -123,45 +122,9 @@ describe("WidgetEditor", () => { ); // Assert - expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); - }); - - it("should display all alignment options in the dropdown", () => { - // Arrange - const alignments: readonly Alignment[] = [ - "block", - "inline", - "full-width", - ]; - jest.spyOn( - CoreWidgetRegistry, - "getSupportedAlignments", - ).mockReturnValue(alignments); - - render( - {}} - onRemove={() => {}} - apiOptions={{ - ...ApiOptions.defaults, - showAlignmentOptions: true, - }} - />, - ); - - // Assert - const options = screen.getAllByRole("option"); - expect(options).toHaveLength(3); - expect(options[0]).toHaveTextContent("block"); - expect(options[1]).toHaveTextContent("inline"); - expect(options[2]).toHaveTextContent("full-width"); + expect( + screen.queryByRole("combobox", {name: "Alignment"}), + ).not.toBeInTheDocument(); }); it("should call onChange when alignment is changed", async () => { @@ -191,8 +154,12 @@ describe("WidgetEditor", () => { ); // Act - const dropdown = screen.getByRole("combobox"); - await userEvent.selectOptions(dropdown, "inline"); + const dropdown = screen.getByRole("combobox", { + name: "Alignment", + }); + await userEvent.click(dropdown); + const option = screen.getByRole("option", {name: "inline"}); + await userEvent.click(option); // Assert expect(onChangeMock).toHaveBeenCalledWith( @@ -229,8 +196,10 @@ describe("WidgetEditor", () => { ); // Assert - const dropdown = screen.getByRole("combobox"); - expect(dropdown).toBeDisabled(); + const dropdown = screen.getByRole("combobox", { + name: "Alignment", + }); + expect(dropdown).toHaveAttribute("aria-disabled", "true"); }); }); }); diff --git a/packages/perseus-editor/src/components/alignment-select.tsx b/packages/perseus-editor/src/components/alignment-select.tsx index 7d81dc8b5fc..ac138aef761 100644 --- a/packages/perseus-editor/src/components/alignment-select.tsx +++ b/packages/perseus-editor/src/components/alignment-select.tsx @@ -1,10 +1,17 @@ import {components} from "@khanacademy/perseus"; -import {sizing} from "@khanacademy/wonder-blocks-tokens"; +import {View} from "@khanacademy/wonder-blocks-core"; +import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; +import {sizing, spacing} from "@khanacademy/wonder-blocks-tokens"; +import {BodyText} from "@khanacademy/wonder-blocks-typography"; +import {StyleSheet} from "aphrodite"; import * as React from "react"; +import {useId} from "react"; import {alignmentInfoMap} from "./util"; import type {Alignment, PerseusWidget} from "@khanacademy/perseus-core"; +import type {StyleType} from "@khanacademy/wonder-blocks-core"; const {InfoTip} = components; @@ -13,6 +20,7 @@ interface Props { widgetInfo: PerseusWidget; isEditingDisabled: boolean; onChange: (e: React.ChangeEvent) => void; + style?: StyleType; } export function AlignmentSelect({ @@ -20,17 +28,53 @@ export function AlignmentSelect({ widgetInfo, isEditingDisabled, onChange, + style, }: Props) { + const labelId = useId(); return ( - <> + + + Alignment + + + { + // Create a synthetic-like event to match the existing + // onChange signature expected by WidgetEditor + const syntheticEvent = { + currentTarget: {value}, + } as React.ChangeEvent; + onChange(syntheticEvent); + }} + placeholder="Select alignment" + style={styles.singleSelectShort} + > + {supportedAlignments.map((alignment) => ( + + ))} + +
    {supportedAlignments.map((alignment, index) => (
  • - - + ); } + +const styles = StyleSheet.create({ + singleSelectShort: { + height: 26, + }, +}); diff --git a/packages/perseus-editor/src/components/widget-editor-settings.tsx b/packages/perseus-editor/src/components/widget-editor-settings.tsx new file mode 100644 index 00000000000..ac71d7aaeab --- /dev/null +++ b/packages/perseus-editor/src/components/widget-editor-settings.tsx @@ -0,0 +1,104 @@ +import {View} from "@khanacademy/wonder-blocks-core"; +import Link from "@khanacademy/wonder-blocks-link"; +import {sizing, spacing} from "@khanacademy/wonder-blocks-tokens"; +import * as React from "react"; + +import {AlignmentSelect} from "./alignment-select"; +import LabeledSwitch from "./labeled-switch"; + +import type {Alignment, PerseusWidget} from "@khanacademy/perseus-core"; + +type BestPractices = { + url: string; + label: string; +}; + +type WidgetEditorSettingsProps = { + bestPractices?: BestPractices; + supportsStaticMode: boolean; + isStatic: boolean; + onStaticChange: (value: boolean) => unknown; + supportsGradedToggle: boolean; + isGraded: boolean; + onGradedChange: (value: boolean) => unknown; + supportedAlignments: ReadonlyArray; + widgetInfo: PerseusWidget; + onAlignmentChange: (e: React.SyntheticEvent) => unknown; + isEditingDisabled: boolean; +}; + +function WidgetEditorSettings(props: WidgetEditorSettingsProps) { + const { + bestPractices, + supportsStaticMode, + isStatic, + onStaticChange, + supportsGradedToggle, + isGraded, + onGradedChange, + supportedAlignments, + widgetInfo, + onAlignmentChange, + isEditingDisabled, + } = props; + + const hasControls = + supportsStaticMode || + supportsGradedToggle || + supportedAlignments.length > 1; + + if (!bestPractices && !hasControls) { + return null; + } + + return ( + + {bestPractices && ( + + + {bestPractices.label} + + + )} + {hasControls && ( +
    + {supportsStaticMode && ( + + )} + {supportsGradedToggle && ( + + )} + {supportedAlignments.length > 1 && ( + + )} +
    + )} +
    + ); +} + +export default WidgetEditorSettings; +export type {BestPractices}; diff --git a/packages/perseus-editor/src/components/widget-editor.tsx b/packages/perseus-editor/src/components/widget-editor.tsx index 99ee2002b0e..8b3de2f8315 100644 --- a/packages/perseus-editor/src/components/widget-editor.tsx +++ b/packages/perseus-editor/src/components/widget-editor.tsx @@ -5,16 +5,12 @@ import { applyDefaultsToWidget, } from "@khanacademy/perseus-core"; import {View} from "@khanacademy/wonder-blocks-core"; -import {Strut} from "@khanacademy/wonder-blocks-layout"; -import Switch from "@khanacademy/wonder-blocks-switch"; -import {spacing} from "@khanacademy/wonder-blocks-tokens"; import trashIcon from "@phosphor-icons/core/bold/trash-bold.svg"; import * as React from "react"; -import {useId} from "react"; -import {AlignmentSelect} from "./alignment-select"; import SectionControlButton from "./section-control-button"; import ToggleableCaret from "./toggleable-caret"; +import WidgetEditorSettings from "./widget-editor-settings"; import type Editor from "../editor"; import type {APIOptions} from "@khanacademy/perseus"; @@ -42,6 +38,12 @@ const _upgradeWidgetInfo = (props: WidgetEditorProps): PerseusWidget => { // We can't call serialize here because this.refs.widget // doesn't exist before this component is mounted. const filteredProps = excludeDenylistKeys(props); + // `graded` and `static` are on the denylist (they're widget-level + // metadata, not widget options), so excludeDenylistKeys strips them. + // We need to preserve them so applyDefaultsToWidget doesn't reset + // them to their defaults. + filteredProps.graded = props.graded; + filteredProps.static = props.static; return applyDefaultsToWidget(filteredProps as PerseusWidget); }; @@ -190,30 +192,6 @@ class WidgetEditor extends React.Component<
- {supportsStaticMode && ( - - )} - {supportsGradedToggle && ( - - )} - {supportedAlignments.length > 1 && ( - - )} + {this.state.showWidget && ( + + )}
unknown; - disabled: boolean; -}) { - const {label, disabled, ...switchProps} = props; - const id = useId(); - return ( - <> - - - - - ); -} - export default WidgetEditor; diff --git a/packages/perseus-editor/src/styles/perseus-editor.css b/packages/perseus-editor/src/styles/perseus-editor.css index 39c3c0791f0..72abc7be1dd 100644 --- a/packages/perseus-editor/src/styles/perseus-editor.css +++ b/packages/perseus-editor/src/styles/perseus-editor.css @@ -108,8 +108,6 @@ code { } .perseus-widget-editor .perseus-widget-editor-title { background-color: #eee; - border: 1px solid #ddd; - border-bottom: 0; font-size: 1.25em; padding: 4px 10px; border-radius: 3px 3px 0 0; @@ -138,8 +136,7 @@ code { } .perseus-widget-editor .perseus-widget-editor-content { border-radius: 0 0 3px 3px; - border-top: 1px solid #ddd; - padding: 10px; + padding: 0 10px 10px; transition: all 0s; } .perseus-widget-editor .perseus-widget-editor-content.leave { diff --git a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx index ccad21f6152..85f06ce4bc2 100644 --- a/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx +++ b/packages/perseus-editor/src/widgets/__tests__/interactive-graph-editor.test.tsx @@ -72,7 +72,7 @@ describe("InteractiveGraphEditor", () => { ); // Act - const dropdown = await screen.findByLabelText("Answer type:"); + const dropdown = await screen.findByLabelText("Answer type"); await userEvent.click(dropdown); await userEvent.click(screen.getByRole("option", {name: "Polygon"})); @@ -810,7 +810,7 @@ describe("InteractiveGraphEditor", () => { }, ); - const dropdown = await screen.findByLabelText("Answer type:"); + const dropdown = await screen.findByLabelText("Answer type"); await userEvent.click(dropdown); expect(screen.getByRole("option", {name: "None"})).toBeInTheDocument(); }); diff --git a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx index afe950dcac0..c1bd667c530 100644 --- a/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx +++ b/packages/perseus-editor/src/widgets/interactive-graph-editor/interactive-graph-editor.tsx @@ -162,6 +162,10 @@ export type Props = { */ class InteractiveGraphEditor extends React.Component { static widgetName = "interactive-graph"; + static bestPractices = { + url: "https://www.khanacademy.org/internal-courses/content-creation-best-practices/xe46daa512cd9c644:question-writing/xe46daa512cd9c644:multiple-choice/a/stems", + label: "Interactive Graph best practices", + }; displayName = "InteractiveGraphEditor"; className = "perseus-widget-interactive-graph"; @@ -388,7 +392,7 @@ class InteractiveGraphEditor extends React.Component { {(graphId) => ( - + { - const {children, label, labelSide = "left", style} = props; + const { + children, + label, + labelSide = "left", + labelSize = "small", + style, + } = props; return (