Skip to content

Upgrade Perseus from 22.7.0 to 75.7.1#14388

Open
rtibbles wants to merge 9 commits into
learningequality:developfrom
rtibbles:save_ka
Open

Upgrade Perseus from 22.7.0 to 75.7.1#14388
rtibbles wants to merge 9 commits into
learningequality:developfrom
rtibbles:save_ka

Conversation

@rtibbles
Copy link
Copy Markdown
Member

@rtibbles rtibbles commented Mar 14, 2026

Summary

Upgrades @khanacademy/perseus from 22.7.0 to 75.7.1 (replaces #13526). Major changes:

  • Replace the vendored KaTeX/MathJax 2.1 hybrid with @khanacademy/mathjax-renderer (MathJax 3)
  • Import Perseus/Wonder Blocks/math-input CSS from packages, with a webpack pre-loader converting rem→px (Perseus assumes 1rem = 10px, Kolibri uses 16px)
  • Adapt to v75's changed widget APIs, state serialization, and removal of "scoring" from the core code
    • Replaced getSerializedState/restoreSerializedState with getUserInput/setInputValue
    • Widget version migration now handled in Kolibri (v75 removed its built-in upgraders)
    • Backward-compatible with answer state saved by the old Perseus version
    • Regenerated translator strings for v75's ICU messages
    • Replaced React's MobileKeypad with a Vue NumericKeypad component using a useKeypad composable that bridges provide/inject with React's KeypadContext
    • Specifically import their perseus core scoring mechanisms to continue to do scoring in the client side
  • Non-Western numeral support (Arabic-Indic, Devanagari, Bengali, Thai, etc.)
    • Normalize to ASCII before scoring so users can answer in their native numerals
    • Localize keypad digits and restored answer state to the content locale
  • Shim react-router-dom-v5-compat — Wonder Blocks imports from react-router@6 APIs that don't exist under our react-router@5, crashing image rendering
  • Fix label-image answer pills being disproportionately large (fixes KA Perseus - The answer pills of a "Make bar graphs" exercise are elongated #14111) — removed legacy padding: 16px on .perseus-renderer that inflated nested renderers inside answer pills; the .perseus container's own padding is sufficient
  • Puts all MathJax font copying into a pre-compile Webpack plugin - so that we don't have to commit them to the repo.
Exercise Screenshot
Multiple choice with images (Identifying Shapes) Shapes
Arabic RTL radio exercise (القواعد) Arabic exercise
Chinese l10n exercise (10以内的数比较大小) Chinese exercise
Selection state (choice highlighted) Selected
Numeric input with MathJax (Make 10) Make 10
Inline images in exercise content (数量的比较) Comparison
Default keypad Arabic-Indic keypad (ar-EG)
Default Arabic
Label-image answer pills (fixes #14111)
Label image pills

Accessibility audit: 1 pre-existing serious violation (valid-lang on .bibliotron-exercise — the lang object prop leaks as [object Object] via Vue's inheritAttrs). Not introduced by this PR.

References

Reviewer guidance

Best reviewed commit-by-commit — the commits are ordered to tell a logical story.

Areas deserving extra attention:

  • PerseusRendererIndex.vue — answer state serialization (~lines 570-640): bridges old serialization format with v75's getUserInput/setInputValue, and handles widget version migration that Perseus v75 dropped

  • PerseusRendererIndex.vue — CSS overrides: rem→px pre-loader, position: fixedrelative for choice indicators, and a surgical CSS reset replacing v22's full Eric Meyer reset (which broke v75's radio layout)

  • Visual spacing change to evaluate: v75's Wonder Blocks design system uses larger text and padding on choices than v22 (matches khanacademy.org). Evaluate whether the increased vertical space is acceptable on smaller screens.

  • NumericKeypad.vue and useKeypad.js — Vue replacement for React's MobileKeypad. Composable bridges provide/inject with React's KeypadContext.

  • numeralNormalization.js — new module. Uses Unicode \p{Nd} to normalize any decimal digit to ASCII, and Intl.NumberFormat to localize back. deepMapStrings applies these to nested answer state.

  • reactRouterShim.js — returns useInRouterContext() → false so Wonder Blocks falls back to plain <a> tags. Without it, inline images crash via ErrorBoundary.

  • buildConfig.js, rewritePerseusUrls.js, rewritePerseusRem.js — webpack pre-loaders for offline font URLs and rem→px conversion. Most of buildPerseus.js was removed in favor of these.

To test: import the QA channel and work through exercises of various types (radio, numeric-input, expression, dropdown).

AI usage

This PR was implemented collaboratively with Claude Code (Opus 4.6) across multiple sessions. Claude researched the Perseus v22→v75 API changes, wrote the implementation and tests, and performed browser verification. All code was reviewed interactively with a /simplify code review pass, and git absorb was used to maintain clean commit history. The human directed architectural decisions (scope of numeral localization, font placement, widget migration approach, rem→px webpack loader, Vue keypad replacing React's MobileKeypad) and corrected course when Claude's approach was wrong (e.g., attempting display-level digit transliteration instead of answer-state restoration, applying a full CSS reset that broke v75's layout).

@github-actions github-actions Bot added DEV: renderers HTML5 apps, videos, exercises, etc. DEV: frontend SIZE: very large labels Mar 14, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 14, 2026

@rtibbles rtibbles force-pushed the save_ka branch 3 times, most recently from 61dcb73 to fcc239b Compare March 16, 2026 00:03
Copy link
Copy Markdown
Member Author

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs some cleanup still, and I think the test coverage might be missing the mark of what is most useful.

Comment thread kolibri/plugins/perseus_viewer/frontend/views/NumericKeypad.vue Outdated
Comment thread kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue Outdated
Comment thread kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue Outdated
Comment thread kolibri/plugins/perseus_viewer/frontend/reactRouterShim.js
Comment thread kolibri/plugins/perseus_viewer/frontend/__tests__/fixtures/dropdown-item.json Outdated
@rtibbles
Copy link
Copy Markdown
Member Author

Two things to verify before I undraft this - the font files are showing as their bare Git LFS pointers, which makes me suspect something has gone wrong there. Also, we've lost context for some perseus translation messages, we should persist that.

Perseus has undergone major version bumps since our last upgrade,
bringing React 18 support, new scoring/migration APIs, and updated
Wonder Blocks dependencies. This updates all @khanacademy packages
to their current versions, adds new required dependencies
(perseus-core, perseus-score, keypad-context, wonder-blocks-announcer,
etc.), removes obsolete ones (katex, create-react-class, etc.), and
bumps React from 16.14 to 18.2. The @swc/helpers package is hoisted
via .npmrc for Perseus's SWC-compiled output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rtibbles and others added 4 commits April 21, 2026 07:07
The old Tex.js used a complex KaTeX-first, MathJax-fallback approach
that loaded MathJax 2.1 via script injection and maintained a custom
render queue. Perseus now ships @khanacademy/mathjax-renderer which
uses MathJax 3 with CHTML output and handles all math rendering
natively. This replaces the entire vendored implementation with a
thin wrapper around MathJaxRenderer, and copies the required MathJax
WOFF fonts to core static assets at build time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously, buildPerseus.js copied CSS files from Perseus and
math-input into frontend/dist/, rewriting font URLs along the way.
Now we import CSS directly from the packages in PerseusRendererIndex
and handle the KA CDN font URL rewriting with a webpack pre-loader
(rewritePerseusUrls.js) configured in buildConfig.js. This removes
the vendored frontend/dist/ directory (CSS, fonts, README) and
simplifies buildPerseus.js to only copy MathJax fonts and extract
translation messages.

Perseus v75 and its Wonder Blocks design tokens assume a 62.5% root
font-size (1rem = 10px), which is the convention on khanacademy.org.
Kolibri uses the browser default (1rem = 16px), so all rem-based
sizing renders 1.6x too large. A second webpack pre-loader
(rewritePerseusRem.js) converts rem to px at build time using the
10px base, applied to Perseus, Wonder Blocks tokens, and math-input
CSS. The full Eric Meyer CSS reset is replaced with a surgical reset
of just fieldset, ol/ul, and table — the full reset was too aggressive
for Perseus v75's Aphrodite-based inline-block layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Perseus v75 introduces several breaking API changes that this commit
addresses:

- React 18: replace render/unmountComponentAtNode with createRoot API
- Scoring: use scorePerseusItem and emptyWidgetsFunctional from
  @khanacademy/perseus-score instead of itemRenderer.scoreInput()
- Item migration: use parseAndMigratePerseusItem from perseus-core
  to migrate item data before rendering
- State serialization: replace getSerializedState/restoreSerializedState
  with getUserInput/handleUserInput. Answer state is now stored as
  {userInput, hintsVisible} instead of {question, hints}
- Backward compatibility: use deriveUserInputFromSerializedState to
  convert previously saved old-format answer state when restoring
  in coach reports
- Widget solvers: update all 17 widget solvers to use the new
  handleUserInput callback instead of onChange/setState/setInputValue
- Dependencies context: add required generateUrl and useVideo providers
- Keypad: import StatefulKeypadContextProvider from @khanacademy/keypad-context
- Remove CSS reset: the aggressive .perseus-root element reset
  is no longer needed with properly scoped Perseus styles
- Remove constants.js (MathJax config filename no longer needed)

Tests cover scoring (radio, numeric-input, expression, dropdown,
input-number, sorter) and state serialization round-trips including
backward compatibility with old serialized state format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sync the translator message catalog with the strings extracted from
Perseus 75.7.1 and math-input 26.4.6. This adds new strings (e.g.
mathInputBox, characterCount, various widget labels), updates changed
messages (e.g. pi error no longer uses HTML code tags), and removes
strings that are no longer used by the updated Perseus version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
rtibbles and others added 4 commits April 21, 2026 07:08
Users typing with non-Western keyboards (Eastern Arabic, Devanagari,
Bengali, Thai, etc.) can now enter answers using their native numeral
characters. The input is silently transliterated to ASCII 0-9 before
reaching Perseus' scoring engine, which only understands parseFloat.

Supports 16 numeral systems covering Arabic, South Asian, Southeast
Asian, and Tibetan scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the React MobileKeypad from @khanacademy/math-input with a
custom Vue component using KDS (Kolibri Design System) buttons and
theme tokens. This gives us full control over styling, accessibility,
and numeral localization without monkey-patching React DOM.

The NumericKeypad uses KButton/KIconButton for all keys, with
internationalized aria-labels from the Perseus translator strings.
A useKeypad composable owns the keypad state and exposes the KeypadAPI
interface that Perseus widgets expect. React's KeypadContext is bridged
via a Provider with a plain JS value object that routes
setKeypadActive(true/false) to the Vue keypad's activate/dismiss.

The keypad supports both FRACTION (4×4 grid) and EXPRESSION (6×4 grid)
layouts, with digit buttons localized to the content locale's native
numeral system (Arabic-Indic, Devanagari, Bengali, Thai, etc.) via
Intl.NumberFormat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When restoring saved answer state into widgets, convert ASCII digits
back to the content locale's native numeral system. This ensures users
who typed ٤٢ (stored as "42" by Phase 1 normalization) see ٤٢ when
returning to an exercise, not the ASCII representation.

Adds localizeNumerals and localizeUserInput as inverses of the existing
normalize functions, with a roundtrip test confirming they are proper
inverses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wonder Blocks (clickable, button, link, etc.) imports
useInRouterContext and useNavigate from react-router-dom-v5-compat,
which re-exports from react-router@6. The Perseus plugin resolves
react-router@5 instead, where these APIs don't exist, causing a
TypeError that crashes image rendering via Perseus's ErrorBoundary.

Provide a minimal shim that returns useInRouterContext=false so
Wonder Blocks falls back to plain <a> tags and standard navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rtibbles rtibbles marked this pull request as ready for review April 21, 2026 15:00
isMobile: this.isMobile,
// We already preprocess all URLs
// we may need to enhance this if we find one of the uses of it is breaking.
staticUrl: url => url,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that staticUrl was replaced by the new generateUrl dependency that is passed directly to the ServerItemRenderer component here. So we may want to remove this staticUrl here too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to be still used by the grapher and protractor widgets: https://github.com/search?q=repo%3AKhan%2Fperseus%20staticUrl&type=code

So I think this has to stay defined for now.

Comment on lines +39 to +40
import '@khanacademy/perseus/styles.css';
import '@khanacademy/math-input/styles.css';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that @khanacademy/perseus@75.1.0 and @khanacademy/math-input@26.4.0 introduced CSS @layer, which is not compliant with our browserlist https://caniuse.com/css-cascade-layers, and it seems we are not adding any polyfill.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this postcss plugin will be our best bet for this: https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-cascade-layers/INSTALL.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we are having problems with images on multi-select widgets.

Before:

Image

After:

Image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems its something to do with this type of question instead

image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I'd fixed this already... clearly not.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like since this release, we can now zoom images, but also some math formulas, where some look a bit strange (because of the unnecessary right space)

Image

Just flagging that this happens now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I had noticed that... it seems unhelpful and unnecessary in most cases.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this custom component something needed due to a given breaking change on Perseus, or is it to have full control over supported i18n? It would be nice to have a comment somewhere on the motivation for this custom implementation.

(I imagine having this as our own component may also be helpful if we want a numeric keypad for the QTI viewer, right?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, as I was trying to update the numeric keypad integration and actually make it appear in a way that wasn't awful, it occurred to me that I could finally fix the i18n issues that plague perseus, and so I did this.

Yes, some explanatory comments of my rage coding would be helpful :)

Comment on lines +85 to +94
<KIconButton
icon="close"
:ariaLabel="closeLabel()"
appearance="flat-button"
size="mini"
:appearanceOverrides="closeBtnStyles"
class="close-btn"
@click="dismiss"
@mousedown.native.prevent
/>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ultra-nitpick 😆) This button node is not a perfect circle.

Image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I saw this and couldn't work out why it wasn't - must be from the closeBtnStyles...


<div
v-if="itemId || itemData"
ref="perseusRoot"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we are not using this perseusRoot anywhere

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove!

if (answerState && answerState.question && answerState.hints) {
answerState = JSON.parse(
replaceImageUrls(JSON.stringify(answerState), this.perseusFileUrl),
restoreAnswerState(answerState) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nitpick) Always wondered why restore... instead of just calling it set...? 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly because it was about taking an answer state that came from props (outside the component) and setting it - I also think this verbiage may have originated in some of the Perseus code it was wrapping originally.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On quizzes, this appears relative to the question container rather than relative to the screen, which hides the answer input

Image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes... now I remember why I made the previous janky positioning for this....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: frontend DEV: renderers HTML5 apps, videos, exercises, etc. SIZE: very large

Projects

None yet

2 participants