From 30333ff7b2b0c5dccee63c45b5ef98a6315c43d2 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 17 Dec 2024 17:04:33 -0800
Subject: [PATCH 01/30] [SR] Linear graph - add grab handle description and
 aria lives

The [SRUX doc](https://khanacademy.atlassian.net/wiki/spaces/LC/pages/3460366348/Linear) still needs a label for the grab handle, but I tried my best in the meantime.

- Add a label and describedby for the grab handle.
- Add aria-live states for the different interactive elements
  so they don't override each other.

Issue: https://khanacademy.atlassian.net/browse/LEMS-1726

Test plan:
`yarn jest packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx`

Storybook
- http://localhost:6006/iframe.html?id=perseuseditor-widgets-interactive-graph--interactive-graph-linear&viewMode=story
- Try all the different slopes and intercepts
- Move different elements and confirm that the updated aria-label
  is what is read out, and none of the other elements override the
  currently focused one.
---
 packages/perseus/src/strings.ts               |  19 ++++
 .../__snapshots__/movable-line.test.tsx.snap  |  18 ++-
 .../graphs/components/movable-line.tsx        |  63 +++++++++--
 .../interactive-graphs/graphs/linear.test.tsx | 103 ++++++++++++++++--
 .../interactive-graphs/graphs/linear.tsx      |   7 ++
 5 files changed, 186 insertions(+), 24 deletions(-)

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index 58ccd2b6d7..6df397b3da 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -209,6 +209,17 @@ export type PerseusStrings = {
         yIntercept: string;
     }) => string;
     srLinearGraphOriginIntercept: string;
+    srLinearGrabHandle: ({
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }: {
+        point1X: string;
+        point1Y: string;
+        point2X: string;
+        point2Y: string;
+    }) => string;
 
     // The above strings are used for interactive graph SR descriptions.
 };
@@ -478,6 +489,12 @@ export const strings: {
             "Screenreader-only description of the line's intercept when the intercept is the graph's origin.",
         message: "The line crosses the x and y axes at the graph's origin.",
     },
+    srLinearGrabHandle: {
+        context:
+            "Screenreader-only label on the grab handle for the line on a linear graph.",
+        message:
+            "Line from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
+    },
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -678,5 +695,7 @@ export const mockStrings: PerseusStrings = {
         `The line crosses the X-axis at ${xIntercept} comma 0 and the Y-axis at 0 comma ${yIntercept}.`,
     srLinearGraphOriginIntercept:
         "The line crosses the x and y axes at the graph's origin.",
+    srLinearGrabHandle: ({point1X, point1Y, point2X, point2Y}) =>
+        `Line from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`,
     // The above strings are used for interactive graph SR descriptions.
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap b/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap
index 560d781cdf..b3e109b8af 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/__snapshots__/movable-line.test.tsx.snap
@@ -16,15 +16,17 @@ exports[`Rendering Does NOT render extensions of line when option is disabled 1`
     >
       <g
         aria-label="Point 1 at -1 comma -1"
-        aria-live="polite"
+        aria-live="off"
         class="movable-point__focusable-handle"
         data-testid="movable-point__focusable-handle"
         role="button"
         tabindex="0"
       />
       <g
+        aria-live="off"
         class="movable-line"
         data-testid="movable-line"
+        role="button"
         style="cursor: grab;"
         tabindex="0"
       >
@@ -61,7 +63,7 @@ exports[`Rendering Does NOT render extensions of line when option is disabled 1`
       </g>
       <g
         aria-label="Point 2 at 1 comma 1"
-        aria-live="polite"
+        aria-live="off"
         class="movable-point__focusable-handle"
         data-testid="movable-point__focusable-handle"
         role="button"
@@ -156,15 +158,17 @@ exports[`Rendering Does NOT render extensions of line when option is not provide
     >
       <g
         aria-label="Point 1 at -1 comma -1"
-        aria-live="polite"
+        aria-live="off"
         class="movable-point__focusable-handle"
         data-testid="movable-point__focusable-handle"
         role="button"
         tabindex="0"
       />
       <g
+        aria-live="off"
         class="movable-line"
         data-testid="movable-line"
+        role="button"
         style="cursor: grab;"
         tabindex="0"
       >
@@ -201,7 +205,7 @@ exports[`Rendering Does NOT render extensions of line when option is not provide
       </g>
       <g
         aria-label="Point 2 at 1 comma 1"
-        aria-live="polite"
+        aria-live="off"
         class="movable-point__focusable-handle"
         data-testid="movable-point__focusable-handle"
         role="button"
@@ -296,15 +300,17 @@ exports[`Rendering Does render extensions of line when option is enabled 1`] = `
     >
       <g
         aria-label="Point 1 at -1 comma -1"
-        aria-live="polite"
+        aria-live="off"
         class="movable-point__focusable-handle"
         data-testid="movable-point__focusable-handle"
         role="button"
         tabindex="0"
       />
       <g
+        aria-live="off"
         class="movable-line"
         data-testid="movable-line"
+        role="button"
         style="cursor: grab;"
         tabindex="0"
       >
@@ -395,7 +401,7 @@ exports[`Rendering Does render extensions of line when option is enabled 1`] = `
       </g>
       <g
         aria-label="Point 2 at 1 comma 1"
-        aria-live="polite"
+        aria-live="off"
         class="movable-point__focusable-handle"
         data-testid="movable-point__focusable-handle"
         role="button"
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx
index 676e0df934..1675b875fb 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/components/movable-line.tsx
@@ -13,6 +13,7 @@ import {SVGLine} from "./svg-line";
 import {useControlPoint} from "./use-control-point";
 import {Vector} from "./vector";
 
+import type {AriaLive} from "../../types";
 import type {Interval} from "mafs";
 
 type Props = {
@@ -20,6 +21,7 @@ type Props = {
     ariaLabels?: {
         point1AriaLabel?: string;
         point2AriaLabel?: string;
+        grabHandleAriaLabel?: string;
     };
     // Extra graph information to be read by screen readers
     ariaDescribedBy?: string;
@@ -35,15 +37,25 @@ type Props = {
 
 export const MovableLine = (props: Props) => {
     const {
+        points: [start, end],
         ariaLabels,
         ariaDescribedBy,
-        onMoveLine = () => {},
-        onMovePoint = () => {},
         color,
-        points: [start, end],
         extend,
+        onMoveLine = () => {},
+        onMovePoint = () => {},
     } = props;
 
+    // Aria live states for (0) point 1, (1) point 2, and (2) grab handle.
+    // When moving an element, set its aria live to "polite" and the others
+    // to "off". Otherwise, other connected elements that move at the same
+    // time might override the currently focused element's aria live.
+    const [ariaLives, setAriaLives] = React.useState<Array<AriaLive>>([
+        "off",
+        "off",
+        "off",
+    ]);
+
     // We use separate focusableHandle elements, instead of letting the movable
     // points themselves be focusable, to allow the tab order of the points to
     // be different from the rendering order. We had to solve for the following
@@ -59,28 +71,42 @@ export const MovableLine = (props: Props) => {
         useControlPoint({
             ariaLabel: ariaLabels?.point1AriaLabel,
             ariaDescribedBy: ariaDescribedBy,
+            ariaLive: ariaLives[0],
             point: start,
             sequenceNumber: 1,
             color,
-            onMove: (p) => onMovePoint(0, p),
+            onMove: (p) => {
+                setAriaLives(["polite", "off", "off"]);
+                onMovePoint(0, p);
+            },
         });
     const {visiblePoint: visiblePoint2, focusableHandle: focusableHandle2} =
         useControlPoint({
             ariaLabel: ariaLabels?.point2AriaLabel,
             ariaDescribedBy: ariaDescribedBy,
+            ariaLive: ariaLives[1],
             point: end,
             sequenceNumber: 2,
             color,
-            onMove: (p) => onMovePoint(1, p),
+            onMove: (p) => {
+                setAriaLives(["off", "polite", "off"]);
+                onMovePoint(1, p);
+            },
         });
 
     const line = (
         <Line
+            ariaLabel={ariaLabels?.grabHandleAriaLabel}
+            ariaDescribedBy={ariaDescribedBy}
+            ariaLive={ariaLives[2]}
             start={start}
             end={end}
             stroke={color}
             extend={extend}
-            onMove={onMoveLine}
+            onMove={(delta) => {
+                setAriaLives(["off", "off", "polite"]);
+                onMoveLine(delta);
+            }}
         />
     );
 
@@ -100,8 +126,9 @@ const defaultStroke = "var(--movable-line-stroke-color)";
 type LineProps = {
     start: vec.Vector2;
     end: vec.Vector2;
-    onMove: (delta: vec.Vector2) => unknown;
-    stroke?: string | undefined;
+    ariaLabel?: string;
+    ariaDescribedBy?: string;
+    ariaLive?: AriaLive;
     /* Extends the line to the edge of the graph with an arrow */
     extend?:
         | undefined
@@ -109,10 +136,21 @@ type LineProps = {
               start: boolean;
               end: boolean;
           };
+    stroke?: string | undefined;
+    onMove: (delta: vec.Vector2) => unknown;
 };
 
 const Line = (props: LineProps) => {
-    const {start, end, onMove, extend, stroke = defaultStroke} = props;
+    const {
+        start,
+        end,
+        ariaLabel,
+        ariaDescribedBy,
+        ariaLive,
+        extend,
+        stroke = defaultStroke,
+        onMove,
+    } = props;
 
     const [startPtPx, endPtPx] = useTransformVectorsToPixels(start, end);
     const {
@@ -150,9 +188,16 @@ const Line = (props: LineProps) => {
             <g
                 ref={line}
                 tabIndex={disableKeyboardInteraction ? -1 : 0}
+                aria-label={ariaLabel}
+                aria-describedby={ariaDescribedBy}
+                aria-live={ariaLive}
                 className="movable-line"
                 data-testid="movable-line"
                 style={{cursor: dragging ? "grabbing" : "grab"}}
+                // Indicate that this element is interactive.
+                // As a bonus, giving this group a non-group role makes
+                // the screen reader skip over its empty children.
+                role="button"
             >
                 {/**
                  * This transparent line creates a nice big click/touch target.
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx
index 6953d153b3..5b514f5879 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx
@@ -1,4 +1,5 @@
 import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
 import * as React from "react";
 
 import {Dependencies} from "@khanacademy/perseus";
@@ -8,6 +9,7 @@ import {MafsGraph} from "../mafs-graph";
 import {getBaseMafsGraphPropsForTests} from "../utils";
 
 import type {InteractiveGraphState} from "../types";
+import type {UserEvent} from "@testing-library/user-event";
 
 const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
 const baseLinearState: InteractiveGraphState = {
@@ -27,7 +29,11 @@ const baseLinearState: InteractiveGraphState = {
 const overallGraphLabel = "A line on a coordinate plane.";
 
 describe("Linear graph screen reader", () => {
+    let userEvent: UserEvent;
     beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
         jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
             testDependencies,
         );
@@ -50,21 +56,36 @@ describe("Linear graph screen reader", () => {
         );
     });
 
-    test("should have aria labels for both points on the line", () => {
+    test("should have aria labels and describedbys for both points and grab handle on the line", () => {
         // Arrange
         render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
 
         // Act
-        // eslint-disable-next-line testing-library/no-node-access
-        const points = screen.getAllByTestId("movable-point__focusable-handle");
+        // Moveable elements: point 1, grab handle, point 2
+        const movableElements = screen.getAllByRole("button");
+        const [point1, grabHandle, point2] = movableElements;
 
         // Assert
-        // Check aria-label for both points on the line.
-        expect(points[0]).toHaveAttribute(
+        // Check aria-label and describedby on interactive elements.
+        // (The actual description text is tested separately below.)
+        expect(point1).toHaveAttribute("aria-label", "Point 1 at -5 comma 5");
+        // We don't know the exact ID because of React.useID(), but we can
+        // check the suffix.
+        expect(point1.getAttribute("aria-describedby")).toContain("-intercept");
+        expect(point1.getAttribute("aria-describedby")).toContain("-slope");
+
+        expect(grabHandle).toHaveAttribute(
             "aria-label",
-            "Point 1 at -5 comma 5",
+            "Line from -5 comma 5 to 5 comma 5.",
         );
-        expect(points[1]).toHaveAttribute("aria-label", "Point 2 at 5 comma 5");
+        expect(grabHandle.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(grabHandle.getAttribute("aria-describedby")).toContain("-slope");
+
+        expect(point2).toHaveAttribute("aria-label", "Point 2 at 5 comma 5");
+        expect(point2.getAttribute("aria-describedby")).toContain("-intercept");
+        expect(point2.getAttribute("aria-describedby")).toContain("-slope");
     });
 
     test("points description should include points info", () => {
@@ -87,7 +108,7 @@ describe("Linear graph screen reader", () => {
         ${"x intercept only"}        | ${[[5, 5], [5, 2]]} | ${"The line crosses the X-axis at 5 comma 0."}
         ${"y intercept only"}        | ${[[5, 5], [2, 5]]} | ${"The line crosses the Y-axis at 0 comma 5."}
     `(
-        `slope description should include slope info for $case`,
+        "slope description should include slope info for $case",
         ({coords, interceptDescription}) => {
             // Arrange
             render(
@@ -115,7 +136,7 @@ describe("Linear graph screen reader", () => {
         ${"horizontal line"} | ${[[1, 1], [3, 1]]} | ${"Its slope is zero."}
         ${"vertical line"}   | ${[[1, 1], [1, 3]]} | ${"Its slope is undefined."}
     `(
-        `slope description should include slope info for $case`,
+        "slope description should include slope info for $case",
         ({coords, slopeDescription}) => {
             // Arrange
             render(
@@ -135,4 +156,68 @@ describe("Linear graph screen reader", () => {
             expect(linearGraph).toHaveTextContent(slopeDescription);
         },
     );
+
+    test("aria label reflects updated values", async () => {
+        // Arrange
+
+        // Act
+        render(
+            <MafsGraph
+                {...baseMafsGraphProps}
+                state={{
+                    ...baseLinearState,
+                    // Different points than default (-5, 5) and (5, 5)
+                    coords: [
+                        [-2, 3],
+                        [3, 3],
+                    ],
+                }}
+            />,
+        );
+
+        const interactiveElements = screen.getAllByRole("button");
+        const [point1, grabHandle, point2] = interactiveElements;
+
+        // Assert
+        // Check updated aria-label for the linear graph.
+        expect(point1).toHaveAttribute("aria-label", "Point 1 at -2 comma 3");
+        expect(grabHandle).toHaveAttribute(
+            "aria-label",
+            "Line from -2 comma 3 to 3 comma 3.",
+        );
+        expect(point2).toHaveAttribute("aria-label", "Point 2 at 3 comma 3");
+    });
+
+    test.each`
+        elementName     | index
+        ${"point1"}     | ${0}
+        ${"grabHandle"} | ${1}
+        ${"point2"}     | ${2}
+    `(
+        "Should update the aria-live when $elementName is moved",
+        async ({index}) => {
+            // Arrange
+            render(
+                <MafsGraph {...baseMafsGraphProps} state={baseLinearState} />,
+            );
+            const interactiveElements = screen.getAllByRole("button");
+            const [point1, grabHandle, point2] = interactiveElements;
+            const movingElement = interactiveElements[index];
+
+            // Act - Move the element
+            movingElement.focus();
+            await userEvent.keyboard("{ArrowRight}");
+
+            const expectedAriaLive = ["off", "off", "off"];
+            expectedAriaLive[index] = "polite";
+
+            // Assert
+            expect(point1).toHaveAttribute("aria-live", expectedAriaLive[0]);
+            expect(grabHandle).toHaveAttribute(
+                "aria-live",
+                expectedAriaLive[1],
+            );
+            expect(point2).toHaveAttribute("aria-live", expectedAriaLive[2]);
+        },
+    );
 });
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
index b20f1d25c4..ccee745c4f 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
@@ -43,6 +43,12 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
         point2X: srFormatNumber(line[1][0], locale),
         point2Y: srFormatNumber(line[1][1], locale),
     });
+    const grabHandleAriaLabel = strings.srLinearGrabHandle({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
 
     // Slope description
     const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
@@ -96,6 +102,7 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
         >
             <MovableLine
                 key={0}
+                ariaLabels={{grabHandleAriaLabel: grabHandleAriaLabel}}
                 ariaDescribedBy={`${interceptDescriptionId} ${slopeDescriptionId}`}
                 points={line}
                 onMoveLine={(delta: vec.Vector2) => {

From 586c962d7ce086c605ff940ed9ececca57f9673f Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 17 Dec 2024 17:08:20 -0800
Subject: [PATCH 02/30] docs(changeset): [SR] Linear graph - add grab handle
 description and aria lives

---
 .changeset/cyan-bees-appear.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/cyan-bees-appear.md

diff --git a/.changeset/cyan-bees-appear.md b/.changeset/cyan-bees-appear.md
new file mode 100644
index 0000000000..8b03f8ce2d
--- /dev/null
+++ b/.changeset/cyan-bees-appear.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+[SR] Linear graph - add grab handle description and aria lives

From a5ef0f87c59c6d08850e6ccccdc4507f09b9824a Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 18 Dec 2024 13:12:24 -0800
Subject: [PATCH 03/30] Linear System SR

---
 packages/perseus/src/strings.ts               |  76 ++++
 .../graphs/linear-system.test.tsx             | 330 ++++++++++++++++++
 .../graphs/linear-system.tsx                  | 100 +++++-
 .../interactive-graphs/graphs/linear.tsx      |  44 +--
 .../interactive-graphs/graphs/utils.ts        |  49 +++
 .../interactive-graphs/mafs-graph.test.tsx    |   8 +-
 6 files changed, 559 insertions(+), 48 deletions(-)
 create mode 100644 packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index 2443644c8c..d97c1ad712 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -258,6 +258,44 @@ export type PerseusStrings = {
         tsX: string;
         tsY: string;
     }) => string;
+    srLinearSystemGraph: string;
+    srLinearSystemPoints: ({
+        lineSequence,
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }: {
+        lineSequence: number;
+        point1X: string;
+        point1Y: string;
+        point2X: string;
+        point2Y: string;
+    }) => string;
+    srLinearSystemPoint({
+        lineSequence,
+        pointSequence,
+        x,
+        y,
+    }: {
+        lineSequence: number;
+        pointSequence: number;
+        x: string;
+        y: string;
+    }): string;
+    srLinearSystemGrabHandle: ({
+        lineSequence,
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }: {
+        lineSequence: number;
+        point1X: string;
+        point1Y: string;
+        point2X: string;
+        point2Y: string;
+    }) => string;
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -550,6 +588,25 @@ export const strings: {
         message:
             "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the initial side at %(isX)s comma %(isY)s and a point on the terminal side at %(tsX)s comma %(tsY)s",
     },
+    srLinearSystemGraph: "Two lines on a coordinate plane.",
+    srLinearSystemPoints: {
+        context:
+            "Additional information about the points for a specific line within the linear system graph.",
+        message:
+            "Line %(lineSequence)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
+    },
+    srLinearSystemPoint: {
+        context:
+            "Screenreader-accessible description of a point on a line within a linear system graph.",
+        message:
+            "Point %(pointSequence)s on line %(lineSequence)s at %(x)s comma %(y)s.",
+    },
+    srLinearSystemGrabHandle: {
+        context:
+            "Screenreader-only label on the grab handle for a line within a linear system graph.",
+        message:
+            "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
+    },
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -767,5 +824,24 @@ export const mockStrings: PerseusStrings = {
         tsY,
     }) =>
         `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the initial side at ${isX} comma ${isY} and a point on the terminal side at ${tsX} comma ${tsY}.`,
+    srLinearSystemGraph: "Two lines on a coordinate plane.",
+    srLinearSystemPoints: ({
+        lineSequence,
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }) =>
+        `Line ${lineSequence} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`,
+    srLinearSystemPoint: ({lineSequence, pointSequence, x, y}) =>
+        `Point ${pointSequence} on line ${lineSequence} at ${x} comma ${y}.`,
+    srLinearSystemGrabHandle: ({
+        lineSequence,
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }) =>
+        `Line ${lineSequence} from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`,
     // The above strings are used for interactive graph SR descriptions.
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
new file mode 100644
index 0000000000..d0b2183c34
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -0,0 +1,330 @@
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {Dependencies} from "@khanacademy/perseus";
+
+import {testDependencies} from "../../../../../../testing/test-dependencies";
+import {MafsGraph} from "../mafs-graph";
+import {getBaseMafsGraphPropsForTests} from "../utils";
+
+import type {InteractiveGraphState} from "../types";
+import type {UserEvent} from "@testing-library/user-event";
+
+const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
+const baseLinearSystemState: InteractiveGraphState = {
+    type: "linear-system",
+    coords: [
+        [
+            [-5, 5],
+            [5, 5],
+        ],
+        [
+            [-5, -5],
+            [5, -5],
+        ],
+    ],
+    hasBeenInteractedWith: false,
+    range: [
+        [-10, 10],
+        [-10, 10],
+    ],
+    snapStep: [1, 1],
+};
+
+const overallGraphLabel = "Two lines on a coordinate plane.";
+
+describe("Linear System graph screen reader", () => {
+    let userEvent: UserEvent;
+    beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
+        jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+            testDependencies,
+        );
+    });
+
+    test("should have aria label and describedby for overall linear system graph", () => {
+        // Arrange
+        render(
+            <MafsGraph {...baseMafsGraphProps} state={baseLinearSystemState} />,
+        );
+
+        // Act
+        const linearSystemGraph = screen.getByLabelText(
+            "Two lines on a coordinate plane.",
+        );
+
+        // Assert
+        expect(linearSystemGraph).toBeInTheDocument();
+        expect(linearSystemGraph).toHaveAttribute(
+            "aria-describedby",
+            ":r1:-line1-points :r1:-line1-intercept :r1:-line1-slope :r1:-line2-points :r1:-line2-intercept :r1:-line2-slope",
+        );
+    });
+
+    test("should have aria labels and describedbys for both points and grab handle on the line", () => {
+        // Arrange
+        render(
+            <MafsGraph {...baseMafsGraphProps} state={baseLinearSystemState} />,
+        );
+
+        // Act
+        // Moveable elements: point 1, grab handle, point 2
+        const movableElements = screen.getAllByRole("button");
+        const [
+            line1Point1,
+            line1Grab,
+            line1Point2,
+            line2Point1,
+            line2Grab,
+            line2Point2,
+        ] = movableElements;
+
+        // Assert
+        // Check aria-label and describedby on interactive elements.
+        // (The actual description text is tested separately below.)
+        expect(line1Point1).toHaveAttribute(
+            "aria-label",
+            "Point 1 on line 1 at -5 comma 5.",
+        );
+        // We don't know the exact ID because of React.useID(), but we can
+        // check the suffix.
+        expect(line1Point1.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(line1Point1.getAttribute("aria-describedby")).toContain(
+            "-slope",
+        );
+
+        expect(line1Grab).toHaveAttribute(
+            "aria-label",
+            "Line 1 from -5 comma 5 to 5 comma 5.",
+        );
+        expect(line1Grab.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(line1Grab.getAttribute("aria-describedby")).toContain("-slope");
+
+        expect(line1Point2).toHaveAttribute(
+            "aria-label",
+            "Point 2 on line 1 at 5 comma 5.",
+        );
+        expect(line1Point2.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(line1Point2.getAttribute("aria-describedby")).toContain(
+            "-slope",
+        );
+
+        expect(line2Point1).toHaveAttribute(
+            "aria-label",
+            "Point 1 on line 2 at -5 comma -5.",
+        );
+        // We don't know the exact ID because of React.useID(), but we can
+        // check the suffix.
+        expect(line2Point1.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(line2Point1.getAttribute("aria-describedby")).toContain(
+            "-slope",
+        );
+
+        expect(line2Grab).toHaveAttribute(
+            "aria-label",
+            "Line 2 from -5 comma -5 to 5 comma -5.",
+        );
+        expect(line2Grab.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(line2Grab.getAttribute("aria-describedby")).toContain("-slope");
+
+        expect(line2Point2).toHaveAttribute(
+            "aria-label",
+            "Point 2 on line 2 at 5 comma -5.",
+        );
+        expect(line2Point2.getAttribute("aria-describedby")).toContain(
+            "-intercept",
+        );
+        expect(line2Point2.getAttribute("aria-describedby")).toContain(
+            "-slope",
+        );
+    });
+
+    test("points description should include points info", () => {
+        // Arrange
+        render(
+            <MafsGraph {...baseMafsGraphProps} state={baseLinearSystemState} />,
+        );
+
+        // Act
+        const linearSystemGraph = screen.getByLabelText(overallGraphLabel);
+
+        // Assert
+        expect(linearSystemGraph).toHaveTextContent(
+            "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.",
+        );
+        expect(linearSystemGraph).toHaveTextContent(
+            "Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.",
+        );
+    });
+
+    // Test each line in the linear system graph separately.
+    describe.each`
+        lineSequence
+        ${1}
+        ${2}
+    `(`Line $lineSequence`, ({lineSequence}) => {
+        test.each`
+            case                         | coords              | interceptDescription
+            ${"origin intercept"}        | ${[[1, 1], [2, 2]]} | ${"The line crosses the x and y axes at the graph's origin."}
+            ${"both x and y intercepts"} | ${[[4, 4], [7, 1]]} | ${"The line crosses the X-axis at 8 comma 0 and the Y-axis at 0 comma 8."}
+            ${"x intercept only"}        | ${[[5, 5], [5, 2]]} | ${"The line crosses the X-axis at 5 comma 0."}
+            ${"y intercept only"}        | ${[[5, 5], [2, 5]]} | ${"The line crosses the Y-axis at 0 comma 5."}
+        `(
+            "slope description should include slope info for $case",
+            ({coords, interceptDescription}) => {
+                // Arrange
+                const newCoords = [...baseLinearSystemState.coords];
+                newCoords[lineSequence - 1] = coords;
+
+                render(
+                    <MafsGraph
+                        {...baseMafsGraphProps}
+                        state={{
+                            ...baseLinearSystemState,
+                            coords: newCoords,
+                        }}
+                    />,
+                );
+
+                // Act
+                const linearSystemGraph =
+                    screen.getByLabelText(overallGraphLabel);
+
+                // Assert
+                expect(linearSystemGraph).toHaveTextContent(
+                    interceptDescription,
+                );
+            },
+        );
+
+        test.each`
+            case                 | coords              | slopeDescription
+            ${"positive slope"}  | ${[[1, 1], [3, 3]]} | ${`Its slope increases from left to right.`}
+            ${"negative slope"}  | ${[[3, 3], [1, 6]]} | ${`Its slope decreases from left to right.`}
+            ${"horizontal line"} | ${[[1, 1], [3, 1]]} | ${`Its slope is zero.`}
+            ${"vertical line"}   | ${[[1, 1], [1, 3]]} | ${`Its slope is undefined.`}
+        `(
+            "slope description should include slope info for $case",
+            ({coords, slopeDescription}) => {
+                // Arrange
+                const newCoords = [...baseLinearSystemState.coords];
+                newCoords[lineSequence - 1] = coords;
+
+                render(
+                    <MafsGraph
+                        {...baseMafsGraphProps}
+                        state={{
+                            ...baseLinearSystemState,
+                            coords: newCoords,
+                        }}
+                    />,
+                );
+
+                // Act
+                const linearSystemGraph =
+                    screen.getByLabelText(overallGraphLabel);
+
+                // Assert
+                expect(linearSystemGraph).toHaveTextContent(slopeDescription);
+            },
+        );
+
+        test("aria label reflects updated values", async () => {
+            // Arrange
+            const newCoords = [...baseLinearSystemState.coords];
+            newCoords[lineSequence - 1] = [
+                [-2, 3],
+                [3, 3],
+            ];
+
+            // Act
+            render(
+                <MafsGraph
+                    {...baseMafsGraphProps}
+                    state={{
+                        ...baseLinearSystemState,
+                        // Different points than default (-5, 5) and (5, 5)
+                        coords: newCoords,
+                    }}
+                />,
+            );
+
+            const interactiveElements = screen.getAllByRole("button");
+
+            // Get interactive elements for this line.
+            const point1 = interactiveElements[0 + (lineSequence - 1) * 3];
+            const grabHandle = interactiveElements[1 + (lineSequence - 1) * 3];
+            const point2 = interactiveElements[2 + (lineSequence - 1) * 3];
+
+            // Assert
+            // Check updated aria-label for the linear graph.
+            expect(point1).toHaveAttribute(
+                "aria-label",
+                `Point 1 on line ${lineSequence} at -2 comma 3.`,
+            );
+            expect(grabHandle).toHaveAttribute(
+                "aria-label",
+                `Line ${lineSequence} from -2 comma 3 to 3 comma 3.`,
+            );
+            expect(point2).toHaveAttribute(
+                "aria-label",
+                `Point 2 on line ${lineSequence} at 3 comma 3.`,
+            );
+        });
+
+        test.each`
+            elementName     | index
+            ${"point1"}     | ${0}
+            ${"grabHandle"} | ${1}
+            ${"point2"}     | ${2}
+        `(
+            "Should update the aria-live when $elementName is moved",
+            async ({index}) => {
+                // Arrange
+                render(
+                    <MafsGraph
+                        {...baseMafsGraphProps}
+                        state={baseLinearSystemState}
+                    />,
+                );
+                const interactiveElements = screen.getAllByRole("button");
+                const [point1, grabHandle, point2] = interactiveElements;
+                const movingElement = interactiveElements[index];
+
+                // Act - Move the element
+                movingElement.focus();
+                await userEvent.keyboard("{ArrowRight}");
+
+                const expectedAriaLive = ["off", "off", "off"];
+                expectedAriaLive[index] = "polite";
+
+                // Assert
+                expect(point1).toHaveAttribute(
+                    "aria-live",
+                    expectedAriaLive[0],
+                );
+                expect(grabHandle).toHaveAttribute(
+                    "aria-live",
+                    expectedAriaLive[1],
+                );
+                expect(point2).toHaveAttribute(
+                    "aria-live",
+                    expectedAriaLive[2],
+                );
+            },
+        );
+    });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
index 05ca19d704..0cd1d04c64 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
@@ -1,8 +1,11 @@
 import * as React from "react";
 
+import {usePerseusI18n} from "../../../components/i18n-context";
 import {actions} from "../reducer/interactive-graph-action";
 
 import {MovableLine} from "./components/movable-line";
+import {srFormatNumber} from "./screenreader-text";
+import {getInterceptStringForLine, getSlopeStringForLine} from "./utils";
 
 import type {
     MafsGraphProps,
@@ -28,12 +31,70 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
     const {dispatch} = props;
     const {coords: lines} = props.graphState;
 
+    const {strings, locale} = usePerseusI18n();
+    const id = React.useId();
+
+    const linesAriaInfo = lines.map((line, i) => {
+        return {
+            pointsDescriptionId: id + `-line${i + 1}-points`,
+            interceptDescriptionId: id + `-line${i + 1}-intercept`,
+            slopeDescriptionId: id + `-line${i + 1}-slope`,
+            pointsDescription: strings.srLinearSystemPoints({
+                lineSequence: i + 1,
+                point1X: srFormatNumber(line[0][0], locale),
+                point1Y: srFormatNumber(line[0][1], locale),
+                point2X: srFormatNumber(line[1][0], locale),
+                point2Y: srFormatNumber(line[1][1], locale),
+            }),
+            interceptDescription: getInterceptStringForLine(
+                line,
+                strings,
+                locale,
+            ),
+            slopeDescription: getSlopeStringForLine(line, strings),
+        };
+    });
+
     return (
-        <>
+        <g
+            aria-label={strings.srLinearSystemGraph}
+            aria-describedby={linesAriaInfo
+                .map(
+                    ({
+                        pointsDescriptionId,
+                        interceptDescriptionId,
+                        slopeDescriptionId,
+                    }) =>
+                        `${pointsDescriptionId} ${interceptDescriptionId} ${slopeDescriptionId}`,
+                )
+                .join(" ")}
+        >
             {lines?.map((line, i) => (
                 <MovableLine
                     key={i}
                     points={line}
+                    ariaLabels={{
+                        point1AriaLabel: strings.srLinearSystemPoint({
+                            lineSequence: i + 1,
+                            pointSequence: 1,
+                            x: srFormatNumber(line[0][0], locale),
+                            y: srFormatNumber(line[0][1], locale),
+                        }),
+                        point2AriaLabel: strings.srLinearSystemPoint({
+                            lineSequence: i + 1,
+                            pointSequence: 2,
+                            x: srFormatNumber(line[1][0], locale),
+                            y: srFormatNumber(line[1][1], locale),
+                        }),
+                        grabHandleAriaLabel: strings.srLinearSystemGrabHandle({
+                            lineSequence: i + 1,
+                            point1X: srFormatNumber(line[0][0], locale),
+                            point1Y: srFormatNumber(line[0][1], locale),
+                            point2X: srFormatNumber(line[1][0], locale),
+                            point2Y: srFormatNumber(line[1][1], locale),
+                        }),
+                    }}
+                    ariaDescribedBy={`${linesAriaInfo[i].interceptDescriptionId} ${linesAriaInfo[i].slopeDescriptionId}`}
                     onMoveLine={(delta: vec.Vector2) => {
                         dispatch(actions.linearSystem.moveLine(i, delta));
                     }}
@@ -56,7 +117,40 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
                     color="var(--movable-line-stroke-color)"
                 />
             ))}
-            ;
-        </>
+            {linesAriaInfo.map(
+                ({
+                    pointsDescriptionId,
+                    interceptDescriptionId,
+                    slopeDescriptionId,
+                    pointsDescription,
+                    interceptDescription,
+                    slopeDescription,
+                }) => (
+                    <>
+                        <g
+                            key={pointsDescriptionId}
+                            id={pointsDescriptionId}
+                            style={{display: "hidden"}}
+                        >
+                            {pointsDescription}
+                        </g>
+                        <g
+                            key={interceptDescriptionId}
+                            id={interceptDescriptionId}
+                            style={{display: "hidden"}}
+                        >
+                            {interceptDescription}
+                        </g>
+                        <g
+                            key={slopeDescriptionId}
+                            id={slopeDescriptionId}
+                            style={{display: "hidden"}}
+                        >
+                            {slopeDescription}
+                        </g>
+                    </>
+                ),
+            )}
+        </g>
     );
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
index ccee745c4f..4cc8eff7c0 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
@@ -5,6 +5,7 @@ import {actions} from "../reducer/interactive-graph-action";
 
 import {MovableLine} from "./components/movable-line";
 import {srFormatNumber} from "./screenreader-text";
+import {getInterceptStringForLine, getSlopeStringForLine} from "./utils";
 
 import type {
     MafsGraphProps,
@@ -49,53 +50,14 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
         point2X: srFormatNumber(line[1][0], locale),
         point2Y: srFormatNumber(line[1][1], locale),
     });
-
-    // Slope description
-    const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
-    let slopeString = "";
-    if (slope === Infinity || slope === -Infinity) {
-        slopeString = strings.srLinearGraphSlopeVertical;
-    } else if (slope === 0) {
-        slopeString = strings.srLinearGraphSlopeHorizontal;
-    } else {
-        slopeString =
-            slope > 0
-                ? strings.srLinearGraphSlopeIncreasing
-                : strings.srLinearGraphSlopeDecreasing;
-    }
-
-    // Intersection description
-    const xIntercept = (0 - line[0][1]) / slope + line[0][0];
-    const yIntercept = line[0][1] - slope * line[0][0];
-    const hasXIntercept = xIntercept !== Infinity && xIntercept !== -Infinity;
-    const hasYIntercept = yIntercept !== Infinity && yIntercept !== -Infinity;
-    let interceptString;
-    if (hasXIntercept && hasYIntercept) {
-        // Describe both intercepts in the same sentence.
-        interceptString =
-            xIntercept === 0 && yIntercept === 0
-                ? strings.srLinearGraphOriginIntercept
-                : strings.srLinearGraphBothIntercepts({
-                      xIntercept: srFormatNumber(xIntercept, locale),
-                      yIntercept: srFormatNumber(yIntercept, locale),
-                  });
-    } else {
-        // Describe only one intercept.
-        interceptString = hasXIntercept
-            ? strings.srLinearGraphXOnlyIntercept({
-                  xIntercept: srFormatNumber(xIntercept, locale),
-              })
-            : strings.srLinearGraphYOnlyIntercept({
-                  yIntercept: srFormatNumber(yIntercept, locale),
-              });
-    }
+    const slopeString = getSlopeStringForLine(line, strings);
+    const interceptString = getInterceptStringForLine(line, strings, locale);
 
     // Linear graphs only have one line
     // (LEMS-2050): Update the reducer so that we have a separate action for moving one line
     // and another action for moving multiple lines
     return (
         <g
-            className="linear-graph-container"
             // Outer line minimal description
             aria-label={strings.srLinearGraph}
             aria-describedby={`${pointsDescriptionId} ${interceptDescriptionId} ${slopeDescriptionId}`}
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
index d42852ec91..72bf8ef0c0 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
@@ -1,3 +1,6 @@
+import {srFormatNumber} from "./screenreader-text";
+
+import type {PairOfPoints} from "../types";
 import type {Coord} from "@khanacademy/perseus";
 import type {Interval, vec} from "mafs";
 
@@ -62,3 +65,49 @@ export function getArrayWithoutDuplicates(array: Array<Coord>): Array<Coord> {
 
     return returnArray;
 }
+
+export function getSlopeStringForLine(line: PairOfPoints, strings): string {
+    const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
+    if (slope === Infinity || slope === -Infinity) {
+        return strings.srLinearGraphSlopeVertical;
+    }
+
+    if (slope === 0) {
+        return strings.srLinearGraphSlopeHorizontal;
+    }
+
+    return slope > 0
+        ? strings.srLinearGraphSlopeIncreasing
+        : strings.srLinearGraphSlopeDecreasing;
+}
+
+export function getInterceptStringForLine(
+    line: PairOfPoints,
+    strings,
+    locale,
+): string {
+    const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
+    const xIntercept = (0 - line[0][1]) / slope + line[0][0];
+    const yIntercept = line[0][1] - slope * line[0][0];
+    const hasXIntercept = xIntercept !== Infinity && xIntercept !== -Infinity;
+    const hasYIntercept = yIntercept !== Infinity && yIntercept !== -Infinity;
+
+    if (hasXIntercept && hasYIntercept) {
+        // Describe both intercepts in the same sentence.
+        return xIntercept === 0 && yIntercept === 0
+            ? strings.srLinearGraphOriginIntercept
+            : strings.srLinearGraphBothIntercepts({
+                  xIntercept: srFormatNumber(xIntercept, locale),
+                  yIntercept: srFormatNumber(yIntercept, locale),
+              });
+    }
+
+    // Describe only one intercept.
+    return hasXIntercept
+        ? strings.srLinearGraphXOnlyIntercept({
+              xIntercept: srFormatNumber(xIntercept, locale),
+          })
+        : strings.srLinearGraphYOnlyIntercept({
+              yIntercept: srFormatNumber(yIntercept, locale),
+          });
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
index d9b4f0d03f..8a831af94c 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
@@ -249,10 +249,10 @@ describe("MafsGraph", () => {
             />,
         );
 
-        expectLabelInDoc("Point 1 at 0 comma 0");
-        expectLabelInDoc("Point 2 at -7 comma 0.5");
-        expectLabelInDoc("Point 1 at 1 comma 1");
-        expectLabelInDoc("Point 2 at 7 comma 0.5");
+        expectLabelInDoc("Point 1 on line 1 at 0 comma 0.");
+        expectLabelInDoc("Point 2 on line 1 at -7 comma 0.5.");
+        expectLabelInDoc("Point 1 on line 2 at 1 comma 1.");
+        expectLabelInDoc("Point 2 on line 2 at 7 comma 0.5.");
     });
 
     it("renders ARIA labels for each point (ray)", () => {

From 0fbbbfa8e2c3002c929a9a0e4a17d3b16bc108d3 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 18 Dec 2024 14:02:07 -0800
Subject: [PATCH 04/30] docs(changeset): [SR] Linear System - add screen reader
 support for Linear System interactive graph

---
 .changeset/tidy-baboons-tie.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/tidy-baboons-tie.md

diff --git a/.changeset/tidy-baboons-tie.md b/.changeset/tidy-baboons-tie.md
new file mode 100644
index 0000000000..20d14a409b
--- /dev/null
+++ b/.changeset/tidy-baboons-tie.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+[SR] Linear System - add screen reader support for Linear System interactive graph

From 48f17c58d928a1c17b82b4cf9308ec3958091f7b Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 18 Dec 2024 15:41:45 -0800
Subject: [PATCH 05/30] Ray SR

---
 packages/perseus/src/strings.ts               |  55 ++++++
 .../interactive-graphs/graphs/ray.test.tsx    | 160 ++++++++++++++++++
 .../widgets/interactive-graphs/graphs/ray.tsx |  60 ++++++-
 .../interactive-graphs/mafs-graph.test.tsx    |   4 +-
 4 files changed, 268 insertions(+), 11 deletions(-)
 create mode 100644 packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index d97c1ad712..c8afcbb1be 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -296,6 +296,31 @@ export type PerseusStrings = {
         point2X: string;
         point2Y: string;
     }) => string;
+    srRayGraph: string;
+    srRayPoints: ({
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }: {
+        point1X: string;
+        point1Y: string;
+        point2X: string;
+        point2Y: string;
+    }) => string;
+    srRayEndpoint: ({x, y}: {x: string; y: string}) => string;
+    srRayTerminalPoint: ({x, y}: {x: string; y: string}) => string;
+    srRayGrabHandle: ({
+        point1X,
+        point1Y,
+        point2X,
+        point2Y,
+    }: {
+        point1X: string;
+        point1Y: string;
+        point2X: string;
+        point2Y: string;
+    }) => string;
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -607,6 +632,29 @@ export const strings: {
         message:
             "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
     },
+    srRayGraph: "A ray on a coordinate plane.",
+    srRayPoints: {
+        context:
+            "Additional information about the points for the ray graph as a whole.",
+        message:
+            "The endpoint is at %(point1X)s comma %(point1Y)s and the terminal point is at %(point2X)s comma %(point2Y)s.",
+    },
+    srRayGrabHandle: {
+        context:
+            "Screenreader-only label on the grab handle for the ray on a ray graph.",
+        message:
+            "Ray from endpoint %(point1X)s comma %(point1Y)s to terminal point %(point2X)s comma %(point2Y)s.",
+    },
+    srRayEndpoint: {
+        context:
+            "Screenreader-only label on the endpoint for the ray on a ray graph.",
+        message: "Endpoint at %(point1X)s comma %(point1Y)s.",
+    },
+    srRayTerminalPoint: {
+        context:
+            "Screenreader-only label on the terminal point for the ray on a ray graph.",
+        message: "Terminal point at %(point2X)s comma %(point2Y)s.",
+    },
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -843,5 +891,12 @@ export const mockStrings: PerseusStrings = {
         point2Y,
     }) =>
         `Line ${lineSequence} from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`,
+    srRayGraph: "A ray on a coordinate plane.",
+    srRayPoints: ({point1X, point1Y, point2X, point2Y}) =>
+        `The endpoint is at ${point1X} comma ${point1Y} and the terminal point is at ${point2X} comma ${point2Y}.`,
+    srRayGrabHandle: ({point1X, point1Y, point2X, point2Y}) =>
+        `Ray from endpoint ${point1X} comma ${point1Y} to terminal point ${point2X} comma ${point2Y}.`,
+    srRayEndpoint: ({x, y}) => `Endpoint at ${x} comma ${y}.`,
+    srRayTerminalPoint: ({x, y}) => `Terminal point at ${x} comma ${y}.`,
     // The above strings are used for interactive graph SR descriptions.
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
new file mode 100644
index 0000000000..a92cf60096
--- /dev/null
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
@@ -0,0 +1,160 @@
+import {render, screen} from "@testing-library/react";
+import {userEvent as userEventLib} from "@testing-library/user-event";
+import * as React from "react";
+
+import {Dependencies} from "@khanacademy/perseus";
+
+import {testDependencies} from "../../../../../../testing/test-dependencies";
+import {MafsGraph} from "../mafs-graph";
+import {getBaseMafsGraphPropsForTests} from "../utils";
+
+import type {InteractiveGraphState} from "../types";
+import type {UserEvent} from "@testing-library/user-event";
+
+const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
+const baseLinearState: InteractiveGraphState = {
+    type: "ray",
+    coords: [
+        [-5, 5],
+        [5, 5],
+    ],
+    hasBeenInteractedWith: false,
+    range: [
+        [-10, 10],
+        [-10, 10],
+    ],
+    snapStep: [1, 1],
+};
+
+const overallGraphLabel = "A ray on a coordinate plane.";
+
+describe("Linear graph screen reader", () => {
+    let userEvent: UserEvent;
+    beforeEach(() => {
+        userEvent = userEventLib.setup({
+            advanceTimers: jest.advanceTimersByTime,
+        });
+        jest.spyOn(Dependencies, "getDependencies").mockReturnValue(
+            testDependencies,
+        );
+    });
+
+    test("should have aria label and describedby for overall linear graph", () => {
+        // Arrange
+        render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
+
+        // Act
+        const linearGraph = screen.getByLabelText(
+            "A ray on a coordinate plane.",
+        );
+
+        // Assert
+        expect(linearGraph).toBeInTheDocument();
+        expect(linearGraph).toHaveAttribute("aria-describedby", ":r1:-points");
+    });
+
+    test("should have aria labels and describedbys for both points and grab handle on the line", () => {
+        // Arrange
+        render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
+
+        // Act
+        // Moveable elements: point 1, grab handle, point 2
+        const movableElements = screen.getAllByRole("button");
+        const [point1, grabHandle, point2] = movableElements;
+
+        // Assert
+        // Check aria-label and describedby on interactive elements.
+        // (The actual description text is tested separately below.)
+        expect(point1).toHaveAttribute("aria-label", "Endpoint at -5 comma 5.");
+
+        expect(grabHandle).toHaveAttribute(
+            "aria-label",
+            "Ray from endpoint -5 comma 5 to terminal point 5 comma 5.",
+        );
+
+        expect(point2).toHaveAttribute(
+            "aria-label",
+            "Terminal point at 5 comma 5.",
+        );
+    });
+
+    test("points description should include points info", () => {
+        // Arrange
+        render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
+
+        // Act
+        const linearGraph = screen.getByLabelText(overallGraphLabel);
+
+        // Assert
+        expect(linearGraph).toHaveTextContent(
+            "The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+        );
+    });
+
+    test("aria label reflects updated values", async () => {
+        // Arrange
+
+        // Act
+        render(
+            <MafsGraph
+                {...baseMafsGraphProps}
+                state={{
+                    ...baseLinearState,
+                    // Different points than default (-5, 5) and (5, 5)
+                    coords: [
+                        [-2, 3],
+                        [3, 3],
+                    ],
+                }}
+            />,
+        );
+
+        const interactiveElements = screen.getAllByRole("button");
+        const [point1, grabHandle, point2] = interactiveElements;
+
+        // Assert
+        // Check updated aria-label for the linear graph.
+        expect(point1).toHaveAttribute("aria-label", "Endpoint at -2 comma 3.");
+        expect(grabHandle).toHaveAttribute(
+            "aria-label",
+            "Ray from endpoint -2 comma 3 to terminal point 3 comma 3.",
+        );
+        expect(point2).toHaveAttribute(
+            "aria-label",
+            "Terminal point at 3 comma 3.",
+        );
+    });
+
+    test.each`
+        elementName     | index
+        ${"point1"}     | ${0}
+        ${"grabHandle"} | ${1}
+        ${"point2"}     | ${2}
+    `(
+        "Should update the aria-live when $elementName is moved",
+        async ({index}) => {
+            // Arrange
+            render(
+                <MafsGraph {...baseMafsGraphProps} state={baseLinearState} />,
+            );
+            const interactiveElements = screen.getAllByRole("button");
+            const [point1, grabHandle, point2] = interactiveElements;
+            const movingElement = interactiveElements[index];
+
+            // Act - Move the element
+            movingElement.focus();
+            await userEvent.keyboard("{ArrowRight}");
+
+            const expectedAriaLive = ["off", "off", "off"];
+            expectedAriaLive[index] = "polite";
+
+            // Assert
+            expect(point1).toHaveAttribute("aria-live", expectedAriaLive[0]);
+            expect(grabHandle).toHaveAttribute(
+                "aria-live",
+                expectedAriaLive[1],
+            );
+            expect(point2).toHaveAttribute("aria-live", expectedAriaLive[2]);
+        },
+    );
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
index 2880e7c5b0..fcc6ecfdaa 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
@@ -1,8 +1,10 @@
 import * as React from "react";
 
+import {usePerseusI18n} from "../../../components/i18n-context";
 import {actions} from "../reducer/interactive-graph-action";
 
 import {MovableLine} from "./components/movable-line";
+import {srFormatNumber} from "./screenreader-text";
 
 import type {
     Dispatch,
@@ -33,16 +35,56 @@ const RayGraph = (props: Props) => {
     const handleMovePoint = (pointIndex: number, newPoint: vec.Vector2) =>
         dispatch(actions.ray.movePoint(pointIndex, newPoint));
 
+    const {strings, locale} = usePerseusI18n();
+    const id = React.useId();
+    const pointsDescriptionId = id + "-points";
+
+    // Aria label strings
+    const linearGraphPointsDescription = strings.srRayPoints({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
+    const grabHandleAriaLabel = strings.srRayGrabHandle({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
+
     // Ray graphs only have one line
     return (
-        <MovableLine
-            points={line}
-            onMoveLine={handleMoveLine}
-            onMovePoint={handleMovePoint}
-            extend={{
-                start: false,
-                end: true,
-            }}
-        />
+        <g
+            // Outer line minimal description
+            aria-label={strings.srRayGraph}
+            aria-describedby={pointsDescriptionId}
+        >
+            <MovableLine
+                points={line}
+                ariaLabels={{
+                    point1AriaLabel: strings.srRayEndpoint({
+                        x: srFormatNumber(line[0][0], locale),
+                        y: srFormatNumber(line[0][1], locale),
+                    }),
+                    point2AriaLabel: strings.srRayTerminalPoint({
+                        x: srFormatNumber(line[1][0], locale),
+                        y: srFormatNumber(line[1][1], locale),
+                    }),
+                    grabHandleAriaLabel: grabHandleAriaLabel,
+                }}
+                onMoveLine={handleMoveLine}
+                onMovePoint={handleMovePoint}
+                extend={{
+                    start: false,
+                    end: true,
+                }}
+            />
+            {/* Hidden elements to provide the descriptions for the
+                circle and radius point's `aria-describedby` properties. */}
+            <g id={pointsDescriptionId} style={{display: "hidden"}}>
+                {linearGraphPointsDescription}
+            </g>
+        </g>
     );
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
index 8a831af94c..d98d227066 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
@@ -278,8 +278,8 @@ describe("MafsGraph", () => {
             />,
         );
 
-        expectLabelInDoc("Point 1 at 0 comma 0");
-        expectLabelInDoc("Point 2 at -7 comma 0.5");
+        expectLabelInDoc("Endpoint at 0 comma 0.");
+        expectLabelInDoc("Terminal point at -7 comma 0.5.");
     });
 
     it("renders ARIA labels for each point (circle)", () => {

From 625c9a4740932bf99e8fc48d91e825f847a23a77 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 18 Dec 2024 15:56:18 -0800
Subject: [PATCH 06/30] docs(changeset): [SR] Ray graph - Add screen reader
 support for Ray interactive graph

---
 .changeset/wild-keys-sit.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/wild-keys-sit.md

diff --git a/.changeset/wild-keys-sit.md b/.changeset/wild-keys-sit.md
new file mode 100644
index 0000000000..6e9702456f
--- /dev/null
+++ b/.changeset/wild-keys-sit.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+[SR] Ray graph - Add screen reader support for Ray interactive graph

From 193ee2a3876fe727c06e80c1654633d81d4d0f1a Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 18 Dec 2024 16:48:27 -0800
Subject: [PATCH 07/30] fix misunderstanding from linear PR. update tests

---
 .../graphs/linear-system.test.tsx             | 133 ++++--------------
 .../interactive-graphs/graphs/linear.tsx      |   1 -
 2 files changed, 27 insertions(+), 107 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index d0b2183c34..e6dfa9bc94 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -64,112 +64,6 @@ describe("Linear System graph screen reader", () => {
         );
     });
 
-    test("should have aria labels and describedbys for both points and grab handle on the line", () => {
-        // Arrange
-        render(
-            <MafsGraph {...baseMafsGraphProps} state={baseLinearSystemState} />,
-        );
-
-        // Act
-        // Moveable elements: point 1, grab handle, point 2
-        const movableElements = screen.getAllByRole("button");
-        const [
-            line1Point1,
-            line1Grab,
-            line1Point2,
-            line2Point1,
-            line2Grab,
-            line2Point2,
-        ] = movableElements;
-
-        // Assert
-        // Check aria-label and describedby on interactive elements.
-        // (The actual description text is tested separately below.)
-        expect(line1Point1).toHaveAttribute(
-            "aria-label",
-            "Point 1 on line 1 at -5 comma 5.",
-        );
-        // We don't know the exact ID because of React.useID(), but we can
-        // check the suffix.
-        expect(line1Point1.getAttribute("aria-describedby")).toContain(
-            "-intercept",
-        );
-        expect(line1Point1.getAttribute("aria-describedby")).toContain(
-            "-slope",
-        );
-
-        expect(line1Grab).toHaveAttribute(
-            "aria-label",
-            "Line 1 from -5 comma 5 to 5 comma 5.",
-        );
-        expect(line1Grab.getAttribute("aria-describedby")).toContain(
-            "-intercept",
-        );
-        expect(line1Grab.getAttribute("aria-describedby")).toContain("-slope");
-
-        expect(line1Point2).toHaveAttribute(
-            "aria-label",
-            "Point 2 on line 1 at 5 comma 5.",
-        );
-        expect(line1Point2.getAttribute("aria-describedby")).toContain(
-            "-intercept",
-        );
-        expect(line1Point2.getAttribute("aria-describedby")).toContain(
-            "-slope",
-        );
-
-        expect(line2Point1).toHaveAttribute(
-            "aria-label",
-            "Point 1 on line 2 at -5 comma -5.",
-        );
-        // We don't know the exact ID because of React.useID(), but we can
-        // check the suffix.
-        expect(line2Point1.getAttribute("aria-describedby")).toContain(
-            "-intercept",
-        );
-        expect(line2Point1.getAttribute("aria-describedby")).toContain(
-            "-slope",
-        );
-
-        expect(line2Grab).toHaveAttribute(
-            "aria-label",
-            "Line 2 from -5 comma -5 to 5 comma -5.",
-        );
-        expect(line2Grab.getAttribute("aria-describedby")).toContain(
-            "-intercept",
-        );
-        expect(line2Grab.getAttribute("aria-describedby")).toContain("-slope");
-
-        expect(line2Point2).toHaveAttribute(
-            "aria-label",
-            "Point 2 on line 2 at 5 comma -5.",
-        );
-        expect(line2Point2.getAttribute("aria-describedby")).toContain(
-            "-intercept",
-        );
-        expect(line2Point2.getAttribute("aria-describedby")).toContain(
-            "-slope",
-        );
-    });
-
-    test("points description should include points info", () => {
-        // Arrange
-        render(
-            <MafsGraph {...baseMafsGraphProps} state={baseLinearSystemState} />,
-        );
-
-        // Act
-        const linearSystemGraph = screen.getByLabelText(overallGraphLabel);
-
-        // Assert
-        expect(linearSystemGraph).toHaveTextContent(
-            "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.",
-        );
-        expect(linearSystemGraph).toHaveTextContent(
-            "Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.",
-        );
-    });
-
     // Test each line in the linear system graph separately.
     describe.each`
         lineSequence
@@ -285,6 +179,33 @@ describe("Linear System graph screen reader", () => {
             );
         });
 
+        test.each`
+            element         | index
+            ${"point1"}     | ${0}
+            ${"grabHandle"} | ${1}
+            ${"point2"}     | ${2}
+        `("should have describedby on all interactive elements", ({index}) => {
+            // Arrange
+            render(
+                <MafsGraph
+                    {...baseMafsGraphProps}
+                    state={baseLinearSystemState}
+                />,
+            );
+
+            // Act
+            const interactiveElements = screen.getAllByRole("button");
+            const element = interactiveElements[index + (lineSequence - 1) * 3];
+
+            // Assert
+            expect(element.getAttribute("aria-describedby")).toContain(
+                "-slope",
+            );
+            expect(element.getAttribute("aria-describedby")).toContain(
+                "-intercept",
+            );
+        });
+
         test.each`
             elementName     | index
             ${"point1"}     | ${0}
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
index e3110b2253..4cc8eff7c0 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
@@ -61,7 +61,6 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
             // Outer line minimal description
             aria-label={strings.srLinearGraph}
             aria-describedby={`${pointsDescriptionId} ${interceptDescriptionId} ${slopeDescriptionId}`}
-            role="figure"
         >
             <MovableLine
                 key={0}

From f754610f169bd547736eaa0c65d458d6eca0828d Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 18 Dec 2024 16:51:59 -0800
Subject: [PATCH 08/30] update test

---
 .../interactive-graphs/graphs/ray.test.tsx    | 42 +++++++++----------
 1 file changed, 20 insertions(+), 22 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
index a92cf60096..f1e654e5c8 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
@@ -53,30 +53,28 @@ describe("Linear graph screen reader", () => {
         expect(linearGraph).toHaveAttribute("aria-describedby", ":r1:-points");
     });
 
-    test("should have aria labels and describedbys for both points and grab handle on the line", () => {
-        // Arrange
-        render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
-
-        // Act
-        // Moveable elements: point 1, grab handle, point 2
-        const movableElements = screen.getAllByRole("button");
-        const [point1, grabHandle, point2] = movableElements;
-
-        // Assert
-        // Check aria-label and describedby on interactive elements.
-        // (The actual description text is tested separately below.)
-        expect(point1).toHaveAttribute("aria-label", "Endpoint at -5 comma 5.");
+    test.each`
+        element         | index | expectedValue
+        ${"point1"}     | ${0}  | ${"Endpoint at -5 comma 5."}
+        ${"grabHandle"} | ${1}  | ${"Ray from endpoint -5 comma 5 to terminal point 5 comma 5."}
+        ${"point2"}     | ${2}  | ${"Terminal point at 5 comma 5."}
+    `(
+        "should have aria label for $element on the line",
+        ({index, expectedValue}) => {
+            // Arrange
+            render(
+                <MafsGraph {...baseMafsGraphProps} state={baseLinearState} />,
+            );
 
-        expect(grabHandle).toHaveAttribute(
-            "aria-label",
-            "Ray from endpoint -5 comma 5 to terminal point 5 comma 5.",
-        );
+            // Act
+            // Moveable elements: point 1, grab handle, point 2
+            const movableElements = screen.getAllByRole("button");
+            const element = movableElements[index];
 
-        expect(point2).toHaveAttribute(
-            "aria-label",
-            "Terminal point at 5 comma 5.",
-        );
-    });
+            // Assert
+            expect(element).toHaveAttribute("aria-label", expectedValue);
+        },
+    );
 
     test("points description should include points info", () => {
         // Arrange

From b315fbfdbc63e4e8bbc3ef00dbf5324cbe8c0644 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 8 Jan 2025 14:49:57 -0800
Subject: [PATCH 09/30] remove contexts from strings

---
 packages/perseus/src/strings.ts | 24 ++++++------------------
 1 file changed, 6 insertions(+), 18 deletions(-)

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index dd593d5105..b234bb5ac8 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -517,24 +517,12 @@ export const strings: {
     srAngleGraphAriaDescription:
         "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the starting side at %(startingSideX)s comma %(startingSideY)s and a point on the ending side at %(endingSideX)s comma %(endingSideY)s",
     srLinearSystemGraph: "Two lines on a coordinate plane.",
-    srLinearSystemPoints: {
-        context:
-            "Additional information about the points for a specific line within the linear system graph.",
-        message:
-            "Line %(lineSequence)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
-    },
-    srLinearSystemPoint: {
-        context:
-            "Screenreader-accessible description of a point on a line within a linear system graph.",
-        message:
-            "Point %(pointSequence)s on line %(lineSequence)s at %(x)s comma %(y)s.",
-    },
-    srLinearSystemGrabHandle: {
-        context:
-            "Screenreader-only label on the grab handle for a line within a linear system graph.",
-        message:
-            "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
-    },
+    srLinearSystemPoints:
+        "Line %(lineSequence)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
+    srLinearSystemPoint:
+        "Point %(pointSequence)s on line %(lineSequence)s at %(x)s comma %(y)s.",
+    srLinearSystemGrabHandle:
+        "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
     // The above strings are used for interactive graph SR descriptions.
 };
 

From 7d96885d5e33daa1ddbb6e466cff07980ca55184 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 8 Jan 2025 15:44:08 -0800
Subject: [PATCH 10/30] Add full graph description of all interactive elements

---
 .../perseus/src/components/i18n-context.tsx   |  2 +-
 .../graphs/linear-system.test.tsx             | 47 +++++++++++++++++++
 .../graphs/linear-system.tsx                  | 47 ++++++++++++++++++-
 3 files changed, 94 insertions(+), 2 deletions(-)

diff --git a/packages/perseus/src/components/i18n-context.tsx b/packages/perseus/src/components/i18n-context.tsx
index 91726b214a..14a696402b 100644
--- a/packages/perseus/src/components/i18n-context.tsx
+++ b/packages/perseus/src/components/i18n-context.tsx
@@ -10,7 +10,7 @@ import {mockStrings} from "../strings";
 
 import type {PerseusStrings} from "../strings";
 
-type I18nContextType = {
+export type I18nContextType = {
     strings: PerseusStrings;
     locale: string;
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index e6dfa9bc94..d69fbe4a1d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -5,9 +5,12 @@ import * as React from "react";
 import {Dependencies} from "@khanacademy/perseus";
 
 import {testDependencies} from "../../../../../../testing/test-dependencies";
+import {mockPerseusI18nContext} from "../../../components/i18n-context";
 import {MafsGraph} from "../mafs-graph";
 import {getBaseMafsGraphPropsForTests} from "../utils";
 
+import {describeLinearSystemGraph} from "./linear-system";
+
 import type {InteractiveGraphState} from "../types";
 import type {UserEvent} from "@testing-library/user-event";
 
@@ -249,3 +252,47 @@ describe("Linear System graph screen reader", () => {
         );
     });
 });
+
+describe(describeLinearSystemGraph, () => {
+    test("describes a default linear system graph", () => {
+        // Arrange
+
+        // Act
+        const linearSystemGraphDescription = describeLinearSystemGraph(
+            baseLinearSystemState,
+            mockPerseusI18nContext,
+        );
+
+        // Assert
+        expect(linearSystemGraphDescription).toEqual(
+            "Interactive elements: Two lines on a coordinate plane. Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.",
+        );
+    });
+
+    test("describes a linear system graph with updated points", () => {
+        // Arrange
+
+        // Act
+        const linearSystemGraphDescription = describeLinearSystemGraph(
+            {
+                ...baseLinearSystemState,
+                coords: [
+                    [
+                        [-2, 3],
+                        [3, 3],
+                    ],
+                    [
+                        [-2, -3],
+                        [3, -3],
+                    ],
+                ],
+            },
+            mockPerseusI18nContext,
+        );
+
+        // Assert
+        expect(linearSystemGraphDescription).toEqual(
+            "Interactive elements: Two lines on a coordinate plane. Line 1 has two points, point 1 at -2 comma 3 and point 2 at 3 comma 3. Line 2 has two points, point 1 at -2 comma -3 and point 2 at 3 comma -3.",
+        );
+    });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
index 0cd1d04c64..2a85c542f6 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
@@ -7,6 +7,7 @@ import {MovableLine} from "./components/movable-line";
 import {srFormatNumber} from "./screenreader-text";
 import {getInterceptStringForLine, getSlopeStringForLine} from "./utils";
 
+import type {I18nContextType} from "../../../components/i18n-context";
 import type {
     MafsGraphProps,
     LinearSystemGraphState,
@@ -21,7 +22,9 @@ export function renderLinearSystemGraph(
 ): InteractiveGraphElementSuite {
     return {
         graph: <LinearSystemGraph graphState={state} dispatch={dispatch} />,
-        interactiveElementsDescription: null,
+        interactiveElementsDescription: (
+            <LinearSystemGraphDescription state={state} />
+        ),
     };
 }
 
@@ -154,3 +157,45 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
         </g>
     );
 };
+
+function LinearSystemGraphDescription({
+    state,
+}: {
+    state: LinearSystemGraphState;
+}) {
+    // The reason that LinearSystemGraphDescription is a component (rather
+    // than a function that returns a string) is because it needs to use a
+    // hook: `usePerseusI18n`.
+    const i18n = usePerseusI18n();
+
+    return describeLinearSystemGraph(state, i18n);
+}
+
+// Exported for testing
+export function describeLinearSystemGraph(
+    state: LinearSystemGraphState,
+    i18n: I18nContextType,
+): string {
+    const {strings, locale} = i18n;
+    const {coords: lines} = state;
+
+    const graphDescription = strings.srLinearSystemGraph;
+
+    const lineDescriptions = lines.map((line, i) => {
+        const point1 = line[0];
+        const point2 = line[1];
+        return strings.srLinearSystemPoints({
+            lineSequence: i + 1,
+            point1X: srFormatNumber(point1[0], locale),
+            point1Y: srFormatNumber(point1[1], locale),
+            point2X: srFormatNumber(point2[0], locale),
+            point2Y: srFormatNumber(point2[1], locale),
+        });
+    });
+
+    const allDescriptions = [graphDescription, ...lineDescriptions];
+
+    return strings.srInteractiveElements({
+        elements: allDescriptions.join(" "),
+    });
+}

From 33e209a6c66e943c54b45d7289d7fc7c4089ed71 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 8 Jan 2025 16:41:21 -0800
Subject: [PATCH 11/30] remove context from strings

---
 packages/perseus/src/strings.ts | 28 ++++++----------------------
 1 file changed, 6 insertions(+), 22 deletions(-)

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index d53b79c834..178769604f 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -549,28 +549,12 @@ export const strings: {
     srLinearSystemGrabHandle:
         "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
     srRayGraph: "A ray on a coordinate plane.",
-    srRayPoints: {
-        context:
-            "Additional information about the points for the ray graph as a whole.",
-        message:
-            "The endpoint is at %(point1X)s comma %(point1Y)s and the terminal point is at %(point2X)s comma %(point2Y)s.",
-    },
-    srRayGrabHandle: {
-        context:
-            "Screenreader-only label on the grab handle for the ray on a ray graph.",
-        message:
-            "Ray from endpoint %(point1X)s comma %(point1Y)s to terminal point %(point2X)s comma %(point2Y)s.",
-    },
-    srRayEndpoint: {
-        context:
-            "Screenreader-only label on the endpoint for the ray on a ray graph.",
-        message: "Endpoint at %(point1X)s comma %(point1Y)s.",
-    },
-    srRayTerminalPoint: {
-        context:
-            "Screenreader-only label on the terminal point for the ray on a ray graph.",
-        message: "Terminal point at %(point2X)s comma %(point2Y)s.",
-    },
+    srRayPoints:
+        "The endpoint is at %(point1X)s comma %(point1Y)s and the terminal point is at %(point2X)s comma %(point2Y)s.",
+    srRayGrabHandle:
+        "Ray from endpoint %(point1X)s comma %(point1Y)s to terminal point %(point2X)s comma %(point2Y)s.",
+    srRayEndpoint: "Endpoint at %(point1X)s comma %(point1Y)s.",
+    srRayTerminalPoint: "Terminal point at %(point2X)s comma %(point2Y)s.",
     // The above strings are used for interactive graph SR descriptions.
 };
 

From 914ea5aa7d986d7878247c549775e0dedc2bf14e Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 8 Jan 2025 16:53:10 -0800
Subject: [PATCH 12/30] Add full graph description of interactive element

---
 .../interactive-graphs/graphs/ray.test.tsx    | 72 ++++++++++++--
 .../widgets/interactive-graphs/graphs/ray.tsx | 93 ++++++++++++++-----
 2 files changed, 131 insertions(+), 34 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
index f1e654e5c8..1389dcbf86 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
@@ -5,14 +5,17 @@ import * as React from "react";
 import {Dependencies} from "@khanacademy/perseus";
 
 import {testDependencies} from "../../../../../../testing/test-dependencies";
+import {mockPerseusI18nContext} from "../../../components/i18n-context";
 import {MafsGraph} from "../mafs-graph";
 import {getBaseMafsGraphPropsForTests} from "../utils";
 
+import {describeRayGraph} from "./ray";
+
 import type {InteractiveGraphState} from "../types";
 import type {UserEvent} from "@testing-library/user-event";
 
 const baseMafsGraphProps = getBaseMafsGraphPropsForTests();
-const baseLinearState: InteractiveGraphState = {
+const baseRayState: InteractiveGraphState = {
     type: "ray",
     coords: [
         [-5, 5],
@@ -41,7 +44,7 @@ describe("Linear graph screen reader", () => {
 
     test("should have aria label and describedby for overall linear graph", () => {
         // Arrange
-        render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
+        render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
 
         // Act
         const linearGraph = screen.getByLabelText(
@@ -62,9 +65,7 @@ describe("Linear graph screen reader", () => {
         "should have aria label for $element on the line",
         ({index, expectedValue}) => {
             // Arrange
-            render(
-                <MafsGraph {...baseMafsGraphProps} state={baseLinearState} />,
-            );
+            render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
 
             // Act
             // Moveable elements: point 1, grab handle, point 2
@@ -78,7 +79,7 @@ describe("Linear graph screen reader", () => {
 
     test("points description should include points info", () => {
         // Arrange
-        render(<MafsGraph {...baseMafsGraphProps} state={baseLinearState} />);
+        render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
 
         // Act
         const linearGraph = screen.getByLabelText(overallGraphLabel);
@@ -97,7 +98,7 @@ describe("Linear graph screen reader", () => {
             <MafsGraph
                 {...baseMafsGraphProps}
                 state={{
-                    ...baseLinearState,
+                    ...baseRayState,
                     // Different points than default (-5, 5) and (5, 5)
                     coords: [
                         [-2, 3],
@@ -132,9 +133,7 @@ describe("Linear graph screen reader", () => {
         "Should update the aria-live when $elementName is moved",
         async ({index}) => {
             // Arrange
-            render(
-                <MafsGraph {...baseMafsGraphProps} state={baseLinearState} />,
-            );
+            render(<MafsGraph {...baseMafsGraphProps} state={baseRayState} />);
             const interactiveElements = screen.getAllByRole("button");
             const [point1, grabHandle, point2] = interactiveElements;
             const movingElement = interactiveElements[index];
@@ -156,3 +155,56 @@ describe("Linear graph screen reader", () => {
         },
     );
 });
+
+describe("describeRayGraph", () => {
+    test("describes a default ray", () => {
+        // Arrange
+
+        // Act
+        const strings = describeRayGraph(baseRayState, mockPerseusI18nContext);
+
+        // Assert
+        expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
+        expect(strings.srRayPoints).toBe(
+            "The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+        );
+        expect(strings.srRayEndpoint).toBe("Endpoint at -5 comma 5.");
+        expect(strings.srRayTerminalPoint).toBe("Terminal point at 5 comma 5.");
+        expect(strings.srRayGrabHandle).toBe(
+            "Ray from endpoint -5 comma 5 to terminal point 5 comma 5.",
+        );
+        expect(strings.srRayInteractiveElement).toBe(
+            "Interactive elements: A ray on a coordinate plane. The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+        );
+    });
+
+    test("describes a ray with updated points", () => {
+        // Arrange
+
+        // Act
+        const strings = describeRayGraph(
+            {
+                ...baseRayState,
+                coords: [
+                    [-1, 2],
+                    [3, 4],
+                ],
+            },
+            mockPerseusI18nContext,
+        );
+
+        // Assert
+        expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
+        expect(strings.srRayPoints).toBe(
+            "The endpoint is at -1 comma 2 and the terminal point is at 3 comma 4.",
+        );
+        expect(strings.srRayEndpoint).toBe("Endpoint at -1 comma 2.");
+        expect(strings.srRayTerminalPoint).toBe("Terminal point at 3 comma 4.");
+        expect(strings.srRayGrabHandle).toBe(
+            "Ray from endpoint -1 comma 2 to terminal point 3 comma 4.",
+        );
+        expect(strings.srRayInteractiveElement).toBe(
+            "Interactive elements: A ray on a coordinate plane. The endpoint is at -1 comma 2 and the terminal point is at 3 comma 4.",
+        );
+    });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
index fcc6ecfdaa..1671192243 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
@@ -6,6 +6,7 @@ import {actions} from "../reducer/interactive-graph-action";
 import {MovableLine} from "./components/movable-line";
 import {srFormatNumber} from "./screenreader-text";
 
+import type {I18nContextType} from "../../../components/i18n-context";
 import type {
     Dispatch,
     InteractiveGraphElementSuite,
@@ -20,7 +21,7 @@ export function renderRayGraph(
 ): InteractiveGraphElementSuite {
     return {
         graph: <RayGraph graphState={state} dispatch={dispatch} />,
-        interactiveElementsDescription: null,
+        interactiveElementsDescription: <RayGraphDescription state={state} />,
     };
 }
 
@@ -40,38 +41,27 @@ const RayGraph = (props: Props) => {
     const pointsDescriptionId = id + "-points";
 
     // Aria label strings
-    const linearGraphPointsDescription = strings.srRayPoints({
-        point1X: srFormatNumber(line[0][0], locale),
-        point1Y: srFormatNumber(line[0][1], locale),
-        point2X: srFormatNumber(line[1][0], locale),
-        point2Y: srFormatNumber(line[1][1], locale),
-    });
-    const grabHandleAriaLabel = strings.srRayGrabHandle({
-        point1X: srFormatNumber(line[0][0], locale),
-        point1Y: srFormatNumber(line[0][1], locale),
-        point2X: srFormatNumber(line[1][0], locale),
-        point2Y: srFormatNumber(line[1][1], locale),
-    });
+    const {
+        srRayGraph,
+        srRayPoints,
+        srRayEndpoint,
+        srRayTerminalPoint,
+        srRayGrabHandle,
+    } = describeRayGraph(props.graphState, {strings, locale});
 
     // Ray graphs only have one line
     return (
         <g
             // Outer line minimal description
-            aria-label={strings.srRayGraph}
+            aria-label={srRayGraph}
             aria-describedby={pointsDescriptionId}
         >
             <MovableLine
                 points={line}
                 ariaLabels={{
-                    point1AriaLabel: strings.srRayEndpoint({
-                        x: srFormatNumber(line[0][0], locale),
-                        y: srFormatNumber(line[0][1], locale),
-                    }),
-                    point2AriaLabel: strings.srRayTerminalPoint({
-                        x: srFormatNumber(line[1][0], locale),
-                        y: srFormatNumber(line[1][1], locale),
-                    }),
-                    grabHandleAriaLabel: grabHandleAriaLabel,
+                    point1AriaLabel: srRayEndpoint,
+                    point2AriaLabel: srRayTerminalPoint,
+                    grabHandleAriaLabel: srRayGrabHandle,
                 }}
                 onMoveLine={handleMoveLine}
                 onMovePoint={handleMovePoint}
@@ -83,8 +73,63 @@ const RayGraph = (props: Props) => {
             {/* Hidden elements to provide the descriptions for the
                 circle and radius point's `aria-describedby` properties. */}
             <g id={pointsDescriptionId} style={{display: "hidden"}}>
-                {linearGraphPointsDescription}
+                {srRayPoints}
             </g>
         </g>
     );
 };
+
+function RayGraphDescription({state}: {state: RayGraphState}) {
+    // The reason that CircleGraphDescription is a component (rather than a
+    // function that returns a string) is because it needs to use a
+    // hook: `usePerseusI18n`.
+    const i18n = usePerseusI18n();
+    const strings = describeRayGraph(state, i18n);
+
+    return strings.srRayInteractiveElement;
+}
+
+// Exported for testing
+export function describeRayGraph(
+    state: RayGraphState,
+    i18n: I18nContextType,
+): Record<string, string> {
+    const {coords: line} = state;
+    const {strings, locale} = i18n;
+
+    // Aria label strings
+    const srRayGraph = strings.srRayGraph;
+    const srRayPoints = strings.srRayPoints({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
+    const srRayEndpoint = strings.srRayEndpoint({
+        x: srFormatNumber(line[0][0], locale),
+        y: srFormatNumber(line[0][1], locale),
+    });
+    const srRayTerminalPoint = strings.srRayTerminalPoint({
+        x: srFormatNumber(line[1][0], locale),
+        y: srFormatNumber(line[1][1], locale),
+    });
+    const srRayGrabHandle = strings.srRayGrabHandle({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
+
+    const srRayInteractiveElement = strings.srInteractiveElements({
+        elements: [srRayGraph, srRayPoints].join(" "),
+    });
+
+    return {
+        srRayGraph,
+        srRayPoints,
+        srRayEndpoint,
+        srRayTerminalPoint,
+        srRayGrabHandle,
+        srRayInteractiveElement,
+    };
+}

From 9ac97821b99dd1eb72468b10ae77dba3c24735f4 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 10:37:55 -0800
Subject: [PATCH 13/30] Rename lineSequence --> lineNumber

---
 packages/perseus/src/strings.ts               | 36 ++++++++-----------
 .../graphs/linear-system.test.tsx             | 24 ++++++-------
 .../graphs/linear-system.tsx                  | 10 +++---
 3 files changed, 32 insertions(+), 38 deletions(-)

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index b234bb5ac8..a27cc36c3e 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -260,37 +260,37 @@ export type PerseusStrings = {
     }) => string;
     srLinearSystemGraph: string;
     srLinearSystemPoints: ({
-        lineSequence,
+        lineNumber,
         point1X,
         point1Y,
         point2X,
         point2Y,
     }: {
-        lineSequence: number;
+        lineNumber: number;
         point1X: string;
         point1Y: string;
         point2X: string;
         point2Y: string;
     }) => string;
     srLinearSystemPoint({
-        lineSequence,
+        lineNumber,
         pointSequence,
         x,
         y,
     }: {
-        lineSequence: number;
+        lineNumber: number;
         pointSequence: number;
         x: string;
         y: string;
     }): string;
     srLinearSystemGrabHandle: ({
-        lineSequence,
+        lineNumber,
         point1X,
         point1Y,
         point2X,
         point2Y,
     }: {
-        lineSequence: number;
+        lineNumber: number;
         point1X: string;
         point1Y: string;
         point2X: string;
@@ -518,11 +518,11 @@ export const strings: {
         "The angle measure is %(angleMeasure)s degrees with a vertex at %(vertexX)s comma %(vertexY)s, a point on the starting side at %(startingSideX)s comma %(startingSideY)s and a point on the ending side at %(endingSideX)s comma %(endingSideY)s",
     srLinearSystemGraph: "Two lines on a coordinate plane.",
     srLinearSystemPoints:
-        "Line %(lineSequence)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
+        "Line %(lineNumber)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
     srLinearSystemPoint:
-        "Point %(pointSequence)s on line %(lineSequence)s at %(x)s comma %(y)s.",
+        "Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.",
     srLinearSystemGrabHandle:
-        "Line %(lineSequence)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
+        "Line %(lineNumber)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -741,23 +741,17 @@ export const mockStrings: PerseusStrings = {
     }) =>
         `The angle measure is ${angleMeasure} degrees with a vertex at ${vertexX} comma ${vertexY}, a point on the starting side at ${startingSideX} comma ${startingSideY} and a point on the ending side at ${endingSideX} comma ${endingSideY}.`,
     srLinearSystemGraph: "Two lines on a coordinate plane.",
-    srLinearSystemPoints: ({
-        lineSequence,
-        point1X,
-        point1Y,
-        point2X,
-        point2Y,
-    }) =>
-        `Line ${lineSequence} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`,
-    srLinearSystemPoint: ({lineSequence, pointSequence, x, y}) =>
-        `Point ${pointSequence} on line ${lineSequence} at ${x} comma ${y}.`,
+    srLinearSystemPoints: ({lineNumber, point1X, point1Y, point2X, point2Y}) =>
+        `Line ${lineNumber} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`,
+    srLinearSystemPoint: ({lineNumber, pointSequence, x, y}) =>
+        `Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`,
     srLinearSystemGrabHandle: ({
-        lineSequence,
+        lineNumber,
         point1X,
         point1Y,
         point2X,
         point2Y,
     }) =>
-        `Line ${lineSequence} from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`,
+        `Line ${lineNumber} from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`,
     // The above strings are used for interactive graph SR descriptions.
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index d69fbe4a1d..31780461b8 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -69,10 +69,10 @@ describe("Linear System graph screen reader", () => {
 
     // Test each line in the linear system graph separately.
     describe.each`
-        lineSequence
+        lineNumber
         ${1}
         ${2}
-    `(`Line $lineSequence`, ({lineSequence}) => {
+    `(`Line $lineNumber`, ({lineNumber}) => {
         test.each`
             case                         | coords              | interceptDescription
             ${"origin intercept"}        | ${[[1, 1], [2, 2]]} | ${"The line crosses the x and y axes at the graph's origin."}
@@ -84,7 +84,7 @@ describe("Linear System graph screen reader", () => {
             ({coords, interceptDescription}) => {
                 // Arrange
                 const newCoords = [...baseLinearSystemState.coords];
-                newCoords[lineSequence - 1] = coords;
+                newCoords[lineNumber - 1] = coords;
 
                 render(
                     <MafsGraph
@@ -118,7 +118,7 @@ describe("Linear System graph screen reader", () => {
             ({coords, slopeDescription}) => {
                 // Arrange
                 const newCoords = [...baseLinearSystemState.coords];
-                newCoords[lineSequence - 1] = coords;
+                newCoords[lineNumber - 1] = coords;
 
                 render(
                     <MafsGraph
@@ -142,7 +142,7 @@ describe("Linear System graph screen reader", () => {
         test("aria label reflects updated values", async () => {
             // Arrange
             const newCoords = [...baseLinearSystemState.coords];
-            newCoords[lineSequence - 1] = [
+            newCoords[lineNumber - 1] = [
                 [-2, 3],
                 [3, 3],
             ];
@@ -162,23 +162,23 @@ describe("Linear System graph screen reader", () => {
             const interactiveElements = screen.getAllByRole("button");
 
             // Get interactive elements for this line.
-            const point1 = interactiveElements[0 + (lineSequence - 1) * 3];
-            const grabHandle = interactiveElements[1 + (lineSequence - 1) * 3];
-            const point2 = interactiveElements[2 + (lineSequence - 1) * 3];
+            const point1 = interactiveElements[0 + (lineNumber - 1) * 3];
+            const grabHandle = interactiveElements[1 + (lineNumber - 1) * 3];
+            const point2 = interactiveElements[2 + (lineNumber - 1) * 3];
 
             // Assert
             // Check updated aria-label for the linear graph.
             expect(point1).toHaveAttribute(
                 "aria-label",
-                `Point 1 on line ${lineSequence} at -2 comma 3.`,
+                `Point 1 on line ${lineNumber} at -2 comma 3.`,
             );
             expect(grabHandle).toHaveAttribute(
                 "aria-label",
-                `Line ${lineSequence} from -2 comma 3 to 3 comma 3.`,
+                `Line ${lineNumber} from -2 comma 3 to 3 comma 3.`,
             );
             expect(point2).toHaveAttribute(
                 "aria-label",
-                `Point 2 on line ${lineSequence} at 3 comma 3.`,
+                `Point 2 on line ${lineNumber} at 3 comma 3.`,
             );
         });
 
@@ -198,7 +198,7 @@ describe("Linear System graph screen reader", () => {
 
             // Act
             const interactiveElements = screen.getAllByRole("button");
-            const element = interactiveElements[index + (lineSequence - 1) * 3];
+            const element = interactiveElements[index + (lineNumber - 1) * 3];
 
             // Assert
             expect(element.getAttribute("aria-describedby")).toContain(
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
index 2a85c542f6..4d3834641b 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
@@ -43,7 +43,7 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
             interceptDescriptionId: id + `-line${i + 1}-intercept`,
             slopeDescriptionId: id + `-line${i + 1}-slope`,
             pointsDescription: strings.srLinearSystemPoints({
-                lineSequence: i + 1,
+                lineNumber: i + 1,
                 point1X: srFormatNumber(line[0][0], locale),
                 point1Y: srFormatNumber(line[0][1], locale),
                 point2X: srFormatNumber(line[1][0], locale),
@@ -78,19 +78,19 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
                     points={line}
                     ariaLabels={{
                         point1AriaLabel: strings.srLinearSystemPoint({
-                            lineSequence: i + 1,
+                            lineNumber: i + 1,
                             pointSequence: 1,
                             x: srFormatNumber(line[0][0], locale),
                             y: srFormatNumber(line[0][1], locale),
                         }),
                         point2AriaLabel: strings.srLinearSystemPoint({
-                            lineSequence: i + 1,
+                            lineNumber: i + 1,
                             pointSequence: 2,
                             x: srFormatNumber(line[1][0], locale),
                             y: srFormatNumber(line[1][1], locale),
                         }),
                         grabHandleAriaLabel: strings.srLinearSystemGrabHandle({
-                            lineSequence: i + 1,
+                            lineNumber: i + 1,
                             point1X: srFormatNumber(line[0][0], locale),
                             point1Y: srFormatNumber(line[0][1], locale),
                             point2X: srFormatNumber(line[1][0], locale),
@@ -185,7 +185,7 @@ export function describeLinearSystemGraph(
         const point1 = line[0];
         const point2 = line[1];
         return strings.srLinearSystemPoints({
-            lineSequence: i + 1,
+            lineNumber: i + 1,
             point1X: srFormatNumber(point1[0], locale),
             point1Y: srFormatNumber(point1[1], locale),
             point2X: srFormatNumber(point2[0], locale),

From 5b48e40b60792fe46049668eb77ddb7eeed427f1 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 13:01:18 -0800
Subject: [PATCH 14/30] Create custom matcher for aria descriptions

---
 config/test/custom-matchers.ts                | 46 +++++++++++++++++++
 .../graphs/linear-system.test.tsx             | 19 ++++++--
 2 files changed, 62 insertions(+), 3 deletions(-)

diff --git a/config/test/custom-matchers.ts b/config/test/custom-matchers.ts
index afddd66c24..9238620c8e 100644
--- a/config/test/custom-matchers.ts
+++ b/config/test/custom-matchers.ts
@@ -15,6 +15,10 @@ declare global {
             toHaveInvalidInput(message?: string | null): R;
             toHaveBeenAnsweredIncorrectly(): R;
             toBeHighlighted(): R;
+            toHaveAriaDescription(
+                expected: string,
+                options?: {repetitionNumber?: number},
+            ): R;
         }
     }
 }
@@ -151,4 +155,46 @@ expect.extend({
             message: () => `Element does not appear to be a part of a widget`,
         };
     },
+
+    toHaveAriaDescription(
+        el: HTMLElement,
+        expected: string,
+        options?: {repetitionNumber?: number},
+    ) {
+        // Get the IDs from the element's aria-describedby attribute
+        const describedByIds =
+            el.getAttribute("aria-describedby")?.split(" ") ?? [];
+
+        // Get the elements with the IDs
+        const describedByElements = describedByIds.map((id) =>
+            document.getElementById(id),
+        );
+
+        // Get the text content of the elements
+        const describedByContents = describedByElements.map((el) =>
+            el?.textContent?.trim(),
+        );
+
+        if (options?.repetitionNumber) {
+            // Check if the expected description shows up the same
+            // amount of times as the repetition number.
+            const count = describedByContents.filter(
+                (desc) => desc === expected,
+            ).length;
+            if (count === options.repetitionNumber) {
+                return {pass: true, message: () => ""};
+            }
+        }
+
+        // Check if the expected description is in the list of descriptions
+        if (describedByContents.includes(expected)) {
+            return {pass: true, message: () => ""};
+        }
+
+        return {
+            pass: false,
+            message: () =>
+                `Element does not have the expected aria description. Expected: "${expected}". Found: "${describedByContents.join('",\n"')}"`,
+        };
+    },
 });
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index 31780461b8..97e0a63458 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -61,9 +61,22 @@ describe("Linear System graph screen reader", () => {
 
         // Assert
         expect(linearSystemGraph).toBeInTheDocument();
-        expect(linearSystemGraph).toHaveAttribute(
-            "aria-describedby",
-            ":r1:-line1-points :r1:-line1-intercept :r1:-line1-slope :r1:-line2-points :r1:-line2-intercept :r1:-line2-slope",
+        expect(linearSystemGraph).toHaveAriaDescription(
+            "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.",
+        );
+        expect(linearSystemGraph).toHaveAriaDescription(
+            "The line crosses the Y-axis at 0 comma 5.",
+        );
+        expect(linearSystemGraph).toHaveAriaDescription("Its slope is zero.", {
+            // The slope is zero for both lines, so it shows up twice in the
+            // aria descriptions.
+            repetitionNumber: 2,
+        });
+        expect(linearSystemGraph).toHaveAriaDescription(
+            "Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.",
+        );
+        expect(linearSystemGraph).toHaveAriaDescription(
+            "The line crosses the Y-axis at 0 comma -5.",
         );
     });
 

From 24f71a132b53d789214d49e31f13c7298a32d927 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 13:04:15 -0800
Subject: [PATCH 15/30] Include ID in template string

---
 .../src/widgets/interactive-graphs/graphs/linear-system.tsx | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
index 4d3834641b..6ee62a441d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
@@ -39,9 +39,9 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
 
     const linesAriaInfo = lines.map((line, i) => {
         return {
-            pointsDescriptionId: id + `-line${i + 1}-points`,
-            interceptDescriptionId: id + `-line${i + 1}-intercept`,
-            slopeDescriptionId: id + `-line${i + 1}-slope`,
+            pointsDescriptionId: `${id}-line${i + 1}-points`,
+            interceptDescriptionId: `${id}-line${i + 1}-intercept`,
+            slopeDescriptionId: `${id}-line${i + 1}-slope`,
             pointsDescription: strings.srLinearSystemPoints({
                 lineNumber: i + 1,
                 point1X: srFormatNumber(line[0][0], locale),

From 8669b75e3df0d3389c8338742ee5629ee8c04a20 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 13:09:46 -0800
Subject: [PATCH 16/30] Use srOnly style instead of invalid display hidden

---
 .../src/widgets/interactive-graphs/graphs/angle.tsx        | 3 ++-
 .../src/widgets/interactive-graphs/graphs/circle.tsx       | 5 +++--
 .../widgets/interactive-graphs/graphs/linear-system.tsx    | 7 ++++---
 .../src/widgets/interactive-graphs/graphs/linear.tsx       | 7 ++++---
 4 files changed, 13 insertions(+), 9 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx
index 0ecb3f8e88..62afcaca5f 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/angle.tsx
@@ -2,6 +2,7 @@ import {vec} from "mafs";
 import * as React from "react";
 
 import {usePerseusI18n} from "../../../components/i18n-context";
+import a11y from "../../../util/a11y";
 import {X, Y, calculateAngleInDegrees, getClockwiseAngle, polar} from "../math";
 import {findIntersectionOfRays} from "../math/geometry";
 import {actions} from "../reducer/interactive-graph-action";
@@ -215,7 +216,7 @@ function AngleGraph(props: AngleGraphProps) {
                 }
                 ariaLabel={initialSideAriaLabel}
             />
-            <g id="angle-description" style={{display: "hidden"}}>
+            <g id="angle-description" style={a11y.srOnly}>
                 {wholeAngleDescription}
             </g>
         </g>
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
index d68849c674..7cc5dbed95 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/circle.tsx
@@ -3,6 +3,7 @@ import * as React from "react";
 import {useRef} from "react";
 
 import {usePerseusI18n} from "../../../components/i18n-context";
+import a11y from "../../../util/a11y";
 import {snap, X, Y} from "../math";
 import {actions} from "../reducer/interactive-graph-action";
 import {getRadius} from "../reducer/interactive-graph-state";
@@ -115,10 +116,10 @@ function CircleGraph(props: CircleGraphProps) {
             />
             {/* Hidden elements to provide the descriptions for the
                 circle and radius point's `aria-describedby` properties. */}
-            <g id={radiusId} style={{display: "hidden"}}>
+            <g id={radiusId} style={a11y.srOnly}>
                 {circleRadiusDescription}
             </g>
-            <g id={outerPointsId} style={{display: "hidden"}}>
+            <g id={outerPointsId} style={a11y.srOnly}>
                 {circleOuterPointsDescription}
             </g>
         </g>
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
index 6ee62a441d..31325c54c6 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
@@ -1,6 +1,7 @@
 import * as React from "react";
 
 import {usePerseusI18n} from "../../../components/i18n-context";
+import a11y from "../../../util/a11y";
 import {actions} from "../reducer/interactive-graph-action";
 
 import {MovableLine} from "./components/movable-line";
@@ -133,21 +134,21 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
                         <g
                             key={pointsDescriptionId}
                             id={pointsDescriptionId}
-                            style={{display: "hidden"}}
+                            style={a11y.srOnly}
                         >
                             {pointsDescription}
                         </g>
                         <g
                             key={interceptDescriptionId}
                             id={interceptDescriptionId}
-                            style={{display: "hidden"}}
+                            style={a11y.srOnly}
                         >
                             {interceptDescription}
                         </g>
                         <g
                             key={slopeDescriptionId}
                             id={slopeDescriptionId}
-                            style={{display: "hidden"}}
+                            style={a11y.srOnly}
                         >
                             {slopeDescription}
                         </g>
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
index 4cc8eff7c0..f0b0f429df 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
@@ -1,6 +1,7 @@
 import * as React from "react";
 
 import {usePerseusI18n} from "../../../components/i18n-context";
+import a11y from "../../../util/a11y";
 import {actions} from "../reducer/interactive-graph-action";
 
 import {MovableLine} from "./components/movable-line";
@@ -86,13 +87,13 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
             />
             {/* Hidden elements to provide the descriptions for the
                 circle and radius point's `aria-describedby` properties. */}
-            <g id={pointsDescriptionId} style={{display: "hidden"}}>
+            <g id={pointsDescriptionId} style={a11y.srOnly}>
                 {linearGraphPointsDescription}
             </g>
-            <g id={interceptDescriptionId} style={{display: "hidden"}}>
+            <g id={interceptDescriptionId} style={a11y.srOnly}>
                 {interceptString}
             </g>
-            <g id={slopeDescriptionId} style={{display: "hidden"}}>
+            <g id={slopeDescriptionId} style={a11y.srOnly}>
                 {slopeString}
             </g>
         </g>

From ed8ccbd8a3c01dbbad8b3f571c4ae1d49471ea2d Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 13:16:37 -0800
Subject: [PATCH 17/30] use isFinite

---
 .../perseus/src/widgets/interactive-graphs/graphs/utils.ts  | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
index 72bf8ef0c0..19febe5dd3 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
@@ -68,7 +68,7 @@ export function getArrayWithoutDuplicates(array: Array<Coord>): Array<Coord> {
 
 export function getSlopeStringForLine(line: PairOfPoints, strings): string {
     const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
-    if (slope === Infinity || slope === -Infinity) {
+    if (!Number.isFinite(slope)) {
         return strings.srLinearGraphSlopeVertical;
     }
 
@@ -89,8 +89,8 @@ export function getInterceptStringForLine(
     const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
     const xIntercept = (0 - line[0][1]) / slope + line[0][0];
     const yIntercept = line[0][1] - slope * line[0][0];
-    const hasXIntercept = xIntercept !== Infinity && xIntercept !== -Infinity;
-    const hasYIntercept = yIntercept !== Infinity && yIntercept !== -Infinity;
+    const hasXIntercept = Number.isFinite(xIntercept);
+    const hasYIntercept = Number.isFinite(yIntercept);
 
     if (hasXIntercept && hasYIntercept) {
         // Describe both intercepts in the same sentence.

From e625ea6e21bc1fd6282d52f902e9cbd694379d0e Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 13:21:17 -0800
Subject: [PATCH 18/30] use srOnly style

---
 packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
index 1671192243..bf720baa2d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
@@ -1,6 +1,7 @@
 import * as React from "react";
 
 import {usePerseusI18n} from "../../../components/i18n-context";
+import a11y from "../../../util/a11y";
 import {actions} from "../reducer/interactive-graph-action";
 
 import {MovableLine} from "./components/movable-line";
@@ -72,7 +73,7 @@ const RayGraph = (props: Props) => {
             />
             {/* Hidden elements to provide the descriptions for the
                 circle and radius point's `aria-describedby` properties. */}
-            <g id={pointsDescriptionId} style={{display: "hidden"}}>
+            <g id={pointsDescriptionId} style={a11y.srOnly}>
                 {srRayPoints}
             </g>
         </g>

From c56b3c3a8aecbbe4ea769243e351334d1e20278a Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Thu, 9 Jan 2025 13:23:13 -0800
Subject: [PATCH 19/30] use new matcher for aria descriptions in test

---
 .../src/widgets/interactive-graphs/graphs/ray.test.tsx        | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
index 1389dcbf86..aafb0eaa19 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
@@ -53,7 +53,9 @@ describe("Linear graph screen reader", () => {
 
         // Assert
         expect(linearGraph).toBeInTheDocument();
-        expect(linearGraph).toHaveAttribute("aria-describedby", ":r1:-points");
+        expect(linearGraph).toHaveAccessibleDescription(
+            "The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+        );
     });
 
     test.each`

From f8c8c118fa79e78196213b2a9bd320e0b8bf15d5 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 14 Jan 2025 21:55:42 -0800
Subject: [PATCH 20/30] handle SR for lines overlapping axes

---
 .../interactive-graphs/graphs/linear-system.test.tsx     | 4 ++++
 .../src/widgets/interactive-graphs/graphs/utils.ts       | 9 +++++++--
 2 files changed, 11 insertions(+), 2 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index 97e0a63458..24d8214b4d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -92,6 +92,8 @@ describe("Linear System graph screen reader", () => {
             ${"both x and y intercepts"} | ${[[4, 4], [7, 1]]} | ${"The line crosses the X-axis at 8 comma 0 and the Y-axis at 0 comma 8."}
             ${"x intercept only"}        | ${[[5, 5], [5, 2]]} | ${"The line crosses the X-axis at 5 comma 0."}
             ${"y intercept only"}        | ${[[5, 5], [2, 5]]} | ${"The line crosses the Y-axis at 0 comma 5."}
+            ${"overlaps y-axis"}         | ${[[0, 5], [0, 2]]} | ${"The line crosses the X-axis at 0 comma 0."}
+            ${"overlaps x-axis"}         | ${[[5, 0], [2, 0]]} | ${"The line crosses the Y-axis at 0 comma 0."}
         `(
             "slope description should include slope info for $case",
             ({coords, interceptDescription}) => {
@@ -126,6 +128,8 @@ describe("Linear System graph screen reader", () => {
             ${"negative slope"}  | ${[[3, 3], [1, 6]]} | ${`Its slope decreases from left to right.`}
             ${"horizontal line"} | ${[[1, 1], [3, 1]]} | ${`Its slope is zero.`}
             ${"vertical line"}   | ${[[1, 1], [1, 3]]} | ${`Its slope is undefined.`}
+            ${"overlaps x-axis"} | ${[[1, 0], [3, 0]]} | ${`Its slope is zero.`}
+            ${"overlaps y-axis"} | ${[[0, 1], [0, 3]]} | ${`Its slope is undefined.`}
         `(
             "slope description should include slope info for $case",
             ({coords, slopeDescription}) => {
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
index 19febe5dd3..508de156af 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
@@ -89,8 +89,13 @@ export function getInterceptStringForLine(
     const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
     const xIntercept = (0 - line[0][1]) / slope + line[0][0];
     const yIntercept = line[0][1] - slope * line[0][0];
-    const hasXIntercept = Number.isFinite(xIntercept);
-    const hasYIntercept = Number.isFinite(yIntercept);
+
+    // Check if the line fully overlaps with an axis.
+    const overlapsXAxis = line[0][1] === 0 && line[1][1] === 0;
+    const overlapsYAxis = line[0][0] === 0 && line[1][0] === 0;
+
+    const hasXIntercept = Number.isFinite(xIntercept) && !overlapsXAxis;
+    const hasYIntercept = Number.isFinite(yIntercept) && !overlapsYAxis;
 
     if (hasXIntercept && hasYIntercept) {
         // Describe both intercepts in the same sentence.

From 1dcf3fd37e1f563584525eab24c863eadfd9856c Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 14 Jan 2025 22:40:06 -0800
Subject: [PATCH 21/30] Use existing aria description matcher rather than
 creating a new one

---
 config/test/custom-matchers.ts                | 46 -------------------
 .../graphs/linear-system.test.tsx             | 18 +-------
 2 files changed, 2 insertions(+), 62 deletions(-)

diff --git a/config/test/custom-matchers.ts b/config/test/custom-matchers.ts
index 9238620c8e..afddd66c24 100644
--- a/config/test/custom-matchers.ts
+++ b/config/test/custom-matchers.ts
@@ -15,10 +15,6 @@ declare global {
             toHaveInvalidInput(message?: string | null): R;
             toHaveBeenAnsweredIncorrectly(): R;
             toBeHighlighted(): R;
-            toHaveAriaDescription(
-                expected: string,
-                options?: {repetitionNumber?: number},
-            ): R;
         }
     }
 }
@@ -155,46 +151,4 @@ expect.extend({
             message: () => `Element does not appear to be a part of a widget`,
         };
     },
-
-    toHaveAriaDescription(
-        el: HTMLElement,
-        expected: string,
-        options?: {repetitionNumber?: number},
-    ) {
-        // Get the IDs from the element's aria-describedby attribute
-        const describedByIds =
-            el.getAttribute("aria-describedby")?.split(" ") ?? [];
-
-        // Get the elements with the IDs
-        const describedByElements = describedByIds.map((id) =>
-            document.getElementById(id),
-        );
-
-        // Get the text content of the elements
-        const describedByContents = describedByElements.map((el) =>
-            el?.textContent?.trim(),
-        );
-
-        if (options?.repetitionNumber) {
-            // Check if the expected description shows up the same
-            // amount of times as the repetition number.
-            const count = describedByContents.filter(
-                (desc) => desc === expected,
-            ).length;
-            if (count === options.repetitionNumber) {
-                return {pass: true, message: () => ""};
-            }
-        }
-
-        // Check if the expected description is in the list of descriptions
-        if (describedByContents.includes(expected)) {
-            return {pass: true, message: () => ""};
-        }
-
-        return {
-            pass: false,
-            message: () =>
-                `Element does not have the expected aria description. Expected: "${expected}". Found: "${describedByContents.join('",\n"')}"`,
-        };
-    },
 });
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index 24d8214b4d..c54f7cae82 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -61,22 +61,8 @@ describe("Linear System graph screen reader", () => {
 
         // Assert
         expect(linearSystemGraph).toBeInTheDocument();
-        expect(linearSystemGraph).toHaveAriaDescription(
-            "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.",
-        );
-        expect(linearSystemGraph).toHaveAriaDescription(
-            "The line crosses the Y-axis at 0 comma 5.",
-        );
-        expect(linearSystemGraph).toHaveAriaDescription("Its slope is zero.", {
-            // The slope is zero for both lines, so it shows up twice in the
-            // aria descriptions.
-            repetitionNumber: 2,
-        });
-        expect(linearSystemGraph).toHaveAriaDescription(
-            "Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5.",
-        );
-        expect(linearSystemGraph).toHaveAriaDescription(
-            "The line crosses the Y-axis at 0 comma -5.",
+        expect(linearSystemGraph).toHaveAccessibleDescription(
+            "Line 1 has two points, ponit 1 at -5 comma 5 and point 2 at 5 comma 5. The line crosses the Y-axis at 0 comma 5. Its slope is zero. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5. The line crosses the Y-axis at 0 comma -5.",
         );
     });
 

From d64ac0d70dd9136f1f80faddcd87c190bbcacdae Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 14 Jan 2025 22:41:56 -0800
Subject: [PATCH 22/30] correct string in test

---
 .../widgets/interactive-graphs/graphs/linear-system.test.tsx    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index c54f7cae82..2bfe75efed 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -62,7 +62,7 @@ describe("Linear System graph screen reader", () => {
         // Assert
         expect(linearSystemGraph).toBeInTheDocument();
         expect(linearSystemGraph).toHaveAccessibleDescription(
-            "Line 1 has two points, ponit 1 at -5 comma 5 and point 2 at 5 comma 5. The line crosses the Y-axis at 0 comma 5. Its slope is zero. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5. The line crosses the Y-axis at 0 comma -5.",
+            "Line 1 has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. The line crosses the Y-axis at 0 comma 5. Its slope is zero. Line 2 has two points, point 1 at -5 comma -5 and point 2 at 5 comma -5. The line crosses the Y-axis at 0 comma -5. Its slope is zero.",
         );
     });
 

From 1728c689d34a6e218ecd4c3e8cf7fa715dcb349c Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 14 Jan 2025 22:45:25 -0800
Subject: [PATCH 23/30] [SR] Linear - Add the interactive elements linear
 description to the whole graph container

Add the interactive Linear graph description to the full graph container.

This adds the "Interactive elements: Line..." description to the outermost graph container.

Issue: https://khanacademy.atlassian.net/browse/LEMS-1726

Test plan:
`yarn jest packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx`

Storybook
- Go to http://localhost:6006/iframe.html?globals=&args=&id=perseuseditor-widgets-interactive-graph--interactive-graph-linear&viewMode=story
- Confirm that there is the "interactive elements: ..." section in the SR tree
- Use screen reader to confirm it's read out loud
---
 .../interactive-graphs/graphs/linear.test.tsx | 70 +++++++++++++++-
 .../interactive-graphs/graphs/linear.tsx      | 83 ++++++++++++++-----
 .../widgets/interactive-graphs/graphs/ray.tsx |  4 +-
 3 files changed, 133 insertions(+), 24 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx
index 68b87e4a48..11c4249058 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.test.tsx
@@ -5,9 +5,12 @@ import * as React from "react";
 import {Dependencies} from "@khanacademy/perseus";
 
 import {testDependencies} from "../../../../../../testing/test-dependencies";
+import {mockPerseusI18nContext} from "../../../components/i18n-context";
 import {MafsGraph} from "../mafs-graph";
 import {getBaseMafsGraphPropsForTests} from "../utils";
 
+import {describeLinearGraph} from "./linear";
+
 import type {InteractiveGraphState} from "../types";
 import type {UserEvent} from "@testing-library/user-event";
 
@@ -50,9 +53,8 @@ describe("Linear graph screen reader", () => {
 
         // Assert
         expect(linearGraph).toBeInTheDocument();
-        expect(linearGraph).toHaveAttribute(
-            "aria-describedby",
-            ":r1:-points :r1:-intercept :r1:-slope",
+        expect(linearGraph).toHaveAccessibleDescription(
+            "The line has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5. The line crosses the Y-axis at 0 comma 5. Its slope is zero.",
         );
     });
 
@@ -239,3 +241,65 @@ describe("Linear graph screen reader", () => {
         },
     );
 });
+
+describe("describeLinearGraph", () => {
+    test("describes a default linear graph", () => {
+        // Arrange
+
+        // Act
+        const strings = describeLinearGraph(
+            baseLinearState,
+            mockPerseusI18nContext,
+        );
+
+        // Assert
+        expect(strings.srLinearGraph).toBe("A line on a coordinate plane.");
+        expect(strings.srLinearGraphPoints).toBe(
+            "The line has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.",
+        );
+        expect(strings.srLinearGrabHandle).toBe(
+            "Line from -5 comma 5 to 5 comma 5.",
+        );
+        expect(strings.slopeString).toBe("Its slope is zero.");
+        expect(strings.interceptString).toBe(
+            "The line crosses the Y-axis at 0 comma 5.",
+        );
+        expect(strings.srLinearInteractiveElement).toBe(
+            "Interactive elements: A line on a coordinate plane. The line has two points, point 1 at -5 comma 5 and point 2 at 5 comma 5.",
+        );
+    });
+
+    test("describes a linear graph with updated points", () => {
+        // Arrange
+
+        // Act
+        const strings = describeLinearGraph(
+            {
+                ...baseLinearState,
+                coords: [
+                    [-1, 2],
+                    [3, 4],
+                ],
+            },
+            mockPerseusI18nContext,
+        );
+
+        // Assert
+        expect(strings.srLinearGraph).toBe("A line on a coordinate plane.");
+        expect(strings.srLinearGraphPoints).toBe(
+            "The line has two points, point 1 at -1 comma 2 and point 2 at 3 comma 4.",
+        );
+        expect(strings.srLinearGrabHandle).toBe(
+            "Line from -1 comma 2 to 3 comma 4.",
+        );
+        expect(strings.slopeString).toBe(
+            "Its slope increases from left to right.",
+        );
+        expect(strings.interceptString).toBe(
+            "The line crosses the X-axis at -5 comma 0 and the Y-axis at 0 comma 2.5.",
+        );
+        expect(strings.srLinearInteractiveElement).toBe(
+            "Interactive elements: A line on a coordinate plane. The line has two points, point 1 at -1 comma 2 and point 2 at 3 comma 4.",
+        );
+    });
+});
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
index f0b0f429df..85f26ca8ab 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear.tsx
@@ -8,6 +8,7 @@ import {MovableLine} from "./components/movable-line";
 import {srFormatNumber} from "./screenreader-text";
 import {getInterceptStringForLine, getSlopeStringForLine} from "./utils";
 
+import type {I18nContextType} from "../../../components/i18n-context";
 import type {
     MafsGraphProps,
     LinearGraphState,
@@ -22,7 +23,9 @@ export function renderLinearGraph(
 ): InteractiveGraphElementSuite {
     return {
         graph: <LinearGraph graphState={state} dispatch={dispatch} />,
-        interactiveElementsDescription: null,
+        interactiveElementsDescription: (
+            <LinearGraphDescription state={state} />
+        ),
     };
 }
 
@@ -38,21 +41,14 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
     const interceptDescriptionId = id + "-intercept";
     const slopeDescriptionId = id + "-slope";
 
-    // Aria label strings
-    const linearGraphPointsDescription = strings.srLinearGraphPoints({
-        point1X: srFormatNumber(line[0][0], locale),
-        point1Y: srFormatNumber(line[0][1], locale),
-        point2X: srFormatNumber(line[1][0], locale),
-        point2Y: srFormatNumber(line[1][1], locale),
-    });
-    const grabHandleAriaLabel = strings.srLinearGrabHandle({
-        point1X: srFormatNumber(line[0][0], locale),
-        point1Y: srFormatNumber(line[0][1], locale),
-        point2X: srFormatNumber(line[1][0], locale),
-        point2Y: srFormatNumber(line[1][1], locale),
-    });
-    const slopeString = getSlopeStringForLine(line, strings);
-    const interceptString = getInterceptStringForLine(line, strings, locale);
+    // Aria strings
+    const {
+        srLinearGraph,
+        srLinearGraphPoints,
+        srLinearGrabHandle,
+        slopeString,
+        interceptString,
+    } = describeLinearGraph(props.graphState, {strings, locale});
 
     // Linear graphs only have one line
     // (LEMS-2050): Update the reducer so that we have a separate action for moving one line
@@ -60,12 +56,12 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
     return (
         <g
             // Outer line minimal description
-            aria-label={strings.srLinearGraph}
+            aria-label={srLinearGraph}
             aria-describedby={`${pointsDescriptionId} ${interceptDescriptionId} ${slopeDescriptionId}`}
         >
             <MovableLine
                 key={0}
-                ariaLabels={{grabHandleAriaLabel: grabHandleAriaLabel}}
+                ariaLabels={{grabHandleAriaLabel: srLinearGrabHandle}}
                 ariaDescribedBy={`${interceptDescriptionId} ${slopeDescriptionId}`}
                 points={line}
                 onMoveLine={(delta: vec.Vector2) => {
@@ -88,7 +84,7 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
             {/* Hidden elements to provide the descriptions for the
                 circle and radius point's `aria-describedby` properties. */}
             <g id={pointsDescriptionId} style={a11y.srOnly}>
-                {linearGraphPointsDescription}
+                {srLinearGraphPoints}
             </g>
             <g id={interceptDescriptionId} style={a11y.srOnly}>
                 {interceptString}
@@ -99,3 +95,52 @@ const LinearGraph = (props: LinearGraphProps, key: number) => {
         </g>
     );
 };
+
+function LinearGraphDescription({state}: {state: LinearGraphState}) {
+    // The reason that LinearGraphDescription is a component (rather than a
+    // function that returns a string) is because it needs to use a
+    // hook: `usePerseusI18n`.
+    const i18n = usePerseusI18n();
+    const strings = describeLinearGraph(state, i18n);
+
+    return strings.srLinearInteractiveElement;
+}
+
+// Exported for testing
+export function describeLinearGraph(
+    state: LinearGraphState,
+    i18n: I18nContextType,
+): Record<string, string> {
+    const {coords: line} = state;
+    const {strings, locale} = i18n;
+
+    // Aria label strings
+    const srLinearGraph = strings.srLinearGraph;
+    const srLinearGraphPoints = strings.srLinearGraphPoints({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
+    const srLinearGrabHandle = strings.srLinearGrabHandle({
+        point1X: srFormatNumber(line[0][0], locale),
+        point1Y: srFormatNumber(line[0][1], locale),
+        point2X: srFormatNumber(line[1][0], locale),
+        point2Y: srFormatNumber(line[1][1], locale),
+    });
+    const slopeString = getSlopeStringForLine(line, strings);
+    const interceptString = getInterceptStringForLine(line, strings, locale);
+
+    const srLinearInteractiveElement = strings.srInteractiveElements({
+        elements: [srLinearGraph, srLinearGraphPoints].join(" "),
+    });
+
+    return {
+        srLinearGraph,
+        srLinearGraphPoints,
+        srLinearGrabHandle,
+        slopeString,
+        interceptString,
+        srLinearInteractiveElement,
+    };
+}
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
index bf720baa2d..fc9aa3e48d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
@@ -72,7 +72,7 @@ const RayGraph = (props: Props) => {
                 }}
             />
             {/* Hidden elements to provide the descriptions for the
-                circle and radius point's `aria-describedby` properties. */}
+                `aria-describedby` properties. */}
             <g id={pointsDescriptionId} style={a11y.srOnly}>
                 {srRayPoints}
             </g>
@@ -81,7 +81,7 @@ const RayGraph = (props: Props) => {
 };
 
 function RayGraphDescription({state}: {state: RayGraphState}) {
-    // The reason that CircleGraphDescription is a component (rather than a
+    // The reason that RayGraphDescription is a component (rather than a
     // function that returns a string) is because it needs to use a
     // hook: `usePerseusI18n`.
     const i18n = usePerseusI18n();

From efb68a8fc11ec7e3ec08dd4a60a02403f8c13062 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 14 Jan 2025 22:47:36 -0800
Subject: [PATCH 24/30] docs(changeset): [SR] Linear - Add the interactive
 elements linear description to the whole graph container

---
 .changeset/lemon-apricots-shop.md | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .changeset/lemon-apricots-shop.md

diff --git a/.changeset/lemon-apricots-shop.md b/.changeset/lemon-apricots-shop.md
new file mode 100644
index 0000000000..06121cace5
--- /dev/null
+++ b/.changeset/lemon-apricots-shop.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+[SR] Linear - Add the interactive elements linear description to the whole graph container

From 91f31667b1eff845a04d46e5159ed99f1d4f08d8 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Tue, 14 Jan 2025 22:49:28 -0800
Subject: [PATCH 25/30] Fix comments

---
 .../perseus/src/widgets/interactive-graphs/graphs/ray.tsx     | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
index bf720baa2d..fc9aa3e48d 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.tsx
@@ -72,7 +72,7 @@ const RayGraph = (props: Props) => {
                 }}
             />
             {/* Hidden elements to provide the descriptions for the
-                circle and radius point's `aria-describedby` properties. */}
+                `aria-describedby` properties. */}
             <g id={pointsDescriptionId} style={a11y.srOnly}>
                 {srRayPoints}
             </g>
@@ -81,7 +81,7 @@ const RayGraph = (props: Props) => {
 };
 
 function RayGraphDescription({state}: {state: RayGraphState}) {
-    // The reason that CircleGraphDescription is a component (rather than a
+    // The reason that RayGraphDescription is a component (rather than a
     // function that returns a string) is because it needs to use a
     // hook: `usePerseusI18n`.
     const i18n = usePerseusI18n();

From 4468c3e867170eb30535d3105930dad49cc617be Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 15 Jan 2025 13:15:23 -0800
Subject: [PATCH 26/30] Add types

---
 .../src/widgets/interactive-graphs/graphs/utils.ts     | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
index 508de156af..9efe55b13a 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
@@ -2,6 +2,7 @@ import {srFormatNumber} from "./screenreader-text";
 
 import type {PairOfPoints} from "../types";
 import type {Coord} from "@khanacademy/perseus";
+import type {PerseusStrings} from "@khanacademy/perseus/strings";
 import type {Interval, vec} from "mafs";
 
 /**
@@ -66,7 +67,10 @@ export function getArrayWithoutDuplicates(array: Array<Coord>): Array<Coord> {
     return returnArray;
 }
 
-export function getSlopeStringForLine(line: PairOfPoints, strings): string {
+export function getSlopeStringForLine(
+    line: PairOfPoints,
+    strings: PerseusStrings,
+): string {
     const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
     if (!Number.isFinite(slope)) {
         return strings.srLinearGraphSlopeVertical;
@@ -83,8 +87,8 @@ export function getSlopeStringForLine(line: PairOfPoints, strings): string {
 
 export function getInterceptStringForLine(
     line: PairOfPoints,
-    strings,
-    locale,
+    strings: PerseusStrings,
+    locale: string,
 ): string {
     const slope = (line[1][1] - line[0][1]) / (line[1][0] - line[0][0]);
     const xIntercept = (0 - line[0][1]) / slope + line[0][0];

From 2a45174badd8122f16e04c87ff726aef3b71bb04 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 15 Jan 2025 13:20:40 -0800
Subject: [PATCH 27/30] fix import

---
 packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
index 9efe55b13a..c023e5d7f6 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
@@ -1,8 +1,8 @@
 import {srFormatNumber} from "./screenreader-text";
 
+import type {PerseusStrings} from "../../../strings";
 import type {PairOfPoints} from "../types";
 import type {Coord} from "@khanacademy/perseus";
-import type {PerseusStrings} from "@khanacademy/perseus/strings";
 import type {Interval, vec} from "mafs";
 
 /**

From e4a7322680a5676a1c77cef01a0b2b32cb7cec27 Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 15 Jan 2025 13:21:25 -0800
Subject: [PATCH 28/30] fix import

---
 packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
index 9efe55b13a..c023e5d7f6 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/utils.ts
@@ -1,8 +1,8 @@
 import {srFormatNumber} from "./screenreader-text";
 
+import type {PerseusStrings} from "../../../strings";
 import type {PairOfPoints} from "../types";
 import type {Coord} from "@khanacademy/perseus";
-import type {PerseusStrings} from "@khanacademy/perseus/strings";
 import type {Interval, vec} from "mafs";
 
 /**

From 11f2a4aa0d1571630f807647c38f7d39c4f77b5d Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 15 Jan 2025 13:29:08 -0800
Subject: [PATCH 29/30] Update grab handle description to match SRUX doc

---
 packages/perseus/src/strings.ts               | 23 -------------------
 .../graphs/linear-system.test.tsx             |  2 +-
 .../graphs/linear-system.tsx                  |  8 +------
 3 files changed, 2 insertions(+), 31 deletions(-)

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index a27cc36c3e..0723bdec92 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -283,19 +283,6 @@ export type PerseusStrings = {
         x: string;
         y: string;
     }): string;
-    srLinearSystemGrabHandle: ({
-        lineNumber,
-        point1X,
-        point1Y,
-        point2X,
-        point2Y,
-    }: {
-        lineNumber: number;
-        point1X: string;
-        point1Y: string;
-        point2X: string;
-        point2Y: string;
-    }) => string;
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -521,8 +508,6 @@ export const strings: {
         "Line %(lineNumber)s has two points, point 1 at %(point1X)s comma %(point1Y)s and point 2 at %(point2X)s comma %(point2Y)s.",
     srLinearSystemPoint:
         "Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.",
-    srLinearSystemGrabHandle:
-        "Line %(lineNumber)s from %(point1X)s comma %(point1Y)s to %(point2X)s comma %(point2Y)s.",
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -745,13 +730,5 @@ export const mockStrings: PerseusStrings = {
         `Line ${lineNumber} has two points, point 1 at ${point1X} comma ${point1Y} and point 2 at ${point2X} comma ${point2Y}.`,
     srLinearSystemPoint: ({lineNumber, pointSequence, x, y}) =>
         `Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`,
-    srLinearSystemGrabHandle: ({
-        lineNumber,
-        point1X,
-        point1Y,
-        point2X,
-        point2Y,
-    }) =>
-        `Line ${lineNumber} from ${point1X} comma ${point1Y} to ${point2X} comma ${point2Y}.`,
     // The above strings are used for interactive graph SR descriptions.
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
index 2bfe75efed..2bda019d36 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.test.tsx
@@ -177,7 +177,7 @@ describe("Linear System graph screen reader", () => {
             );
             expect(grabHandle).toHaveAttribute(
                 "aria-label",
-                `Line ${lineNumber} from -2 comma 3 to 3 comma 3.`,
+                `The line crosses the Y-axis at 0 comma 3. Its slope is zero.`,
             );
             expect(point2).toHaveAttribute(
                 "aria-label",
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
index 31325c54c6..d6a38cf885 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/linear-system.tsx
@@ -90,13 +90,7 @@ const LinearSystemGraph = (props: LinearSystemGraphProps) => {
                             x: srFormatNumber(line[1][0], locale),
                             y: srFormatNumber(line[1][1], locale),
                         }),
-                        grabHandleAriaLabel: strings.srLinearSystemGrabHandle({
-                            lineNumber: i + 1,
-                            point1X: srFormatNumber(line[0][0], locale),
-                            point1Y: srFormatNumber(line[0][1], locale),
-                            point2X: srFormatNumber(line[1][0], locale),
-                            point2Y: srFormatNumber(line[1][1], locale),
-                        }),
+                        grabHandleAriaLabel: `${linesAriaInfo[i].interceptDescription} ${linesAriaInfo[i].slopeDescription}`,
                     }}
                     ariaDescribedBy={`${linesAriaInfo[i].interceptDescriptionId} ${linesAriaInfo[i].slopeDescriptionId}`}
                     onMoveLine={(delta: vec.Vector2) => {

From a164397ecc5dd986e5c46c82f7bfaa4a9a63767f Mon Sep 17 00:00:00 2001
From: Nisha Yerunkar <nisha@khanacademy.org>
Date: Wed, 15 Jan 2025 13:39:23 -0800
Subject: [PATCH 30/30] Update copy to match SRUX doc

---
 packages/perseus/src/strings.ts               | 12 ++++----
 .../interactive-graphs/graphs/ray.test.tsx    | 28 +++++++++----------
 .../interactive-graphs/mafs-graph.test.tsx    |  2 +-
 3 files changed, 21 insertions(+), 21 deletions(-)

diff --git a/packages/perseus/src/strings.ts b/packages/perseus/src/strings.ts
index 097f35f0ca..65aa84c3f6 100644
--- a/packages/perseus/src/strings.ts
+++ b/packages/perseus/src/strings.ts
@@ -535,11 +535,11 @@ export const strings: {
         "Point %(pointSequence)s on line %(lineNumber)s at %(x)s comma %(y)s.",
     srRayGraph: "A ray on a coordinate plane.",
     srRayPoints:
-        "The endpoint is at %(point1X)s comma %(point1Y)s and the terminal point is at %(point2X)s comma %(point2Y)s.",
+        "The endpoint is at %(point1X)s comma %(point1Y)s and the ray goes through point %(point2X)s comma %(point2Y)s.",
     srRayGrabHandle:
-        "Ray from endpoint %(point1X)s comma %(point1Y)s to terminal point %(point2X)s comma %(point2Y)s.",
+        "Ray with endpoint %(point1X)s comma %(point1Y)s going through point %(point2X)s comma %(point2Y)s.",
     srRayEndpoint: "Endpoint at %(point1X)s comma %(point1Y)s.",
-    srRayTerminalPoint: "Terminal point at %(point2X)s comma %(point2Y)s.",
+    srRayTerminalPoint: "Through point at %(point2X)s comma %(point2Y)s.",
     // The above strings are used for interactive graph SR descriptions.
 };
 
@@ -764,10 +764,10 @@ export const mockStrings: PerseusStrings = {
         `Point ${pointSequence} on line ${lineNumber} at ${x} comma ${y}.`,
     srRayGraph: "A ray on a coordinate plane.",
     srRayPoints: ({point1X, point1Y, point2X, point2Y}) =>
-        `The endpoint is at ${point1X} comma ${point1Y} and the terminal point is at ${point2X} comma ${point2Y}.`,
+        `The endpoint is at ${point1X} comma ${point1Y} and the ray goes through point ${point2X} comma ${point2Y}.`,
     srRayGrabHandle: ({point1X, point1Y, point2X, point2Y}) =>
-        `Ray from endpoint ${point1X} comma ${point1Y} to terminal point ${point2X} comma ${point2Y}.`,
+        `Ray with endpoint ${point1X} comma ${point1Y} going through point ${point2X} comma ${point2Y}.`,
     srRayEndpoint: ({x, y}) => `Endpoint at ${x} comma ${y}.`,
-    srRayTerminalPoint: ({x, y}) => `Terminal point at ${x} comma ${y}.`,
+    srRayTerminalPoint: ({x, y}) => `Through point at ${x} comma ${y}.`,
     // The above strings are used for interactive graph SR descriptions.
 };
diff --git a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
index aafb0eaa19..f87795306f 100644
--- a/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/graphs/ray.test.tsx
@@ -54,15 +54,15 @@ describe("Linear graph screen reader", () => {
         // Assert
         expect(linearGraph).toBeInTheDocument();
         expect(linearGraph).toHaveAccessibleDescription(
-            "The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+            "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
         );
     });
 
     test.each`
         element         | index | expectedValue
         ${"point1"}     | ${0}  | ${"Endpoint at -5 comma 5."}
-        ${"grabHandle"} | ${1}  | ${"Ray from endpoint -5 comma 5 to terminal point 5 comma 5."}
-        ${"point2"}     | ${2}  | ${"Terminal point at 5 comma 5."}
+        ${"grabHandle"} | ${1}  | ${"Ray with endpoint -5 comma 5 going through point 5 comma 5."}
+        ${"point2"}     | ${2}  | ${"Through point at 5 comma 5."}
     `(
         "should have aria label for $element on the line",
         ({index, expectedValue}) => {
@@ -88,7 +88,7 @@ describe("Linear graph screen reader", () => {
 
         // Assert
         expect(linearGraph).toHaveTextContent(
-            "The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+            "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
         );
     });
 
@@ -118,11 +118,11 @@ describe("Linear graph screen reader", () => {
         expect(point1).toHaveAttribute("aria-label", "Endpoint at -2 comma 3.");
         expect(grabHandle).toHaveAttribute(
             "aria-label",
-            "Ray from endpoint -2 comma 3 to terminal point 3 comma 3.",
+            "Ray with endpoint -2 comma 3 going through point 3 comma 3.",
         );
         expect(point2).toHaveAttribute(
             "aria-label",
-            "Terminal point at 3 comma 3.",
+            "Through point at 3 comma 3.",
         );
     });
 
@@ -168,15 +168,15 @@ describe("describeRayGraph", () => {
         // Assert
         expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
         expect(strings.srRayPoints).toBe(
-            "The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+            "The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
         );
         expect(strings.srRayEndpoint).toBe("Endpoint at -5 comma 5.");
-        expect(strings.srRayTerminalPoint).toBe("Terminal point at 5 comma 5.");
+        expect(strings.srRayTerminalPoint).toBe("Through point at 5 comma 5.");
         expect(strings.srRayGrabHandle).toBe(
-            "Ray from endpoint -5 comma 5 to terminal point 5 comma 5.",
+            "Ray with endpoint -5 comma 5 going through point 5 comma 5.",
         );
         expect(strings.srRayInteractiveElement).toBe(
-            "Interactive elements: A ray on a coordinate plane. The endpoint is at -5 comma 5 and the terminal point is at 5 comma 5.",
+            "Interactive elements: A ray on a coordinate plane. The endpoint is at -5 comma 5 and the ray goes through point 5 comma 5.",
         );
     });
 
@@ -198,15 +198,15 @@ describe("describeRayGraph", () => {
         // Assert
         expect(strings.srRayGraph).toBe("A ray on a coordinate plane.");
         expect(strings.srRayPoints).toBe(
-            "The endpoint is at -1 comma 2 and the terminal point is at 3 comma 4.",
+            "The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.",
         );
         expect(strings.srRayEndpoint).toBe("Endpoint at -1 comma 2.");
-        expect(strings.srRayTerminalPoint).toBe("Terminal point at 3 comma 4.");
+        expect(strings.srRayTerminalPoint).toBe("Through point at 3 comma 4.");
         expect(strings.srRayGrabHandle).toBe(
-            "Ray from endpoint -1 comma 2 to terminal point 3 comma 4.",
+            "Ray with endpoint -1 comma 2 going through point 3 comma 4.",
         );
         expect(strings.srRayInteractiveElement).toBe(
-            "Interactive elements: A ray on a coordinate plane. The endpoint is at -1 comma 2 and the terminal point is at 3 comma 4.",
+            "Interactive elements: A ray on a coordinate plane. The endpoint is at -1 comma 2 and the ray goes through point 3 comma 4.",
         );
     });
 });
diff --git a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
index 6353d6556a..673baef605 100644
--- a/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
+++ b/packages/perseus/src/widgets/interactive-graphs/mafs-graph.test.tsx
@@ -279,7 +279,7 @@ describe("MafsGraph", () => {
         );
 
         expectLabelInDoc("Endpoint at 0 comma 0.");
-        expectLabelInDoc("Terminal point at -7 comma 0.5.");
+        expectLabelInDoc("Through point at -7 comma 0.5.");
     });
 
     it("renders ARIA labels for each point (circle)", () => {