Upgrade Perseus from 22.7.0 to 75.7.1#14388
Conversation
Build Artifacts
Smoke test screenshot |
61dcb73 to
fcc239b
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Needs some cleanup still, and I think the test coverage might be missing the mark of what is most useful.
060f920 to
10b34e9
Compare
a993e0a to
10db741
Compare
bca2a38 to
aee8e87
Compare
|
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>
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>
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>
| 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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| import '@khanacademy/perseus/styles.css'; | ||
| import '@khanacademy/math-input/styles.css'; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
I thought I'd fixed this already... clearly not.
There was a problem hiding this comment.
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)
Just flagging that this happens now.
There was a problem hiding this comment.
Yeah, I had noticed that... it seems unhelpful and unnecessary in most cases.
There was a problem hiding this comment.
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?)
There was a problem hiding this comment.
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 :)
| <KIconButton | ||
| icon="close" | ||
| :ariaLabel="closeLabel()" | ||
| appearance="flat-button" | ||
| size="mini" | ||
| :appearanceOverrides="closeBtnStyles" | ||
| class="close-btn" | ||
| @click="dismiss" | ||
| @mousedown.native.prevent | ||
| /> |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
Seems like we are not using this perseusRoot anywhere
| if (answerState && answerState.question && answerState.hints) { | ||
| answerState = JSON.parse( | ||
| replaceImageUrls(JSON.stringify(answerState), this.perseusFileUrl), | ||
| restoreAnswerState(answerState) { |
There was a problem hiding this comment.
(nitpick) Always wondered why restore... instead of just calling it set...? 😅
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Ah yes... now I remember why I made the previous janky positioning for this....





Summary
Upgrades
@khanacademy/perseusfrom 22.7.0 to 75.7.1 (replaces #13526). Major changes:@khanacademy/mathjax-renderer(MathJax 3)1rem = 10px, Kolibri uses16px)getSerializedState/restoreSerializedStatewithgetUserInput/setInputValueMobileKeypadwith a VueNumericKeypadcomponent using auseKeypadcomposable that bridges provide/inject with React's KeypadContextreact-router-dom-v5-compat— Wonder Blocks imports fromreact-router@6APIs that don't exist under ourreact-router@5, crashing image renderingpadding: 16pxon.perseus-rendererthat inflated nested renderers inside answer pills; the.perseuscontainer's own padding is sufficientAccessibility audit: 1 pre-existing serious violation (
valid-langon.bibliotron-exercise— thelangobject prop leaks as[object Object]via Vue'sinheritAttrs). 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'sgetUserInput/setInputValue, and handles widget version migration that Perseus v75 droppedPerseusRendererIndex.vue— CSS overrides: rem→px pre-loader,position: fixed→relativefor 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.vueanduseKeypad.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, andIntl.NumberFormatto localize back.deepMapStringsapplies these to nested answer state.reactRouterShim.js— returnsuseInRouterContext() → falseso 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 ofbuildPerseus.jswas 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
/simplifycode review pass, andgit absorbwas 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).