Skip to content

Upgrade Perseus from 22.7.0 to 75.7.1#14388

Draft
rtibbles wants to merge 9 commits intolearningequality:developfrom
rtibbles:save_ka
Draft

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

Conversation

@rtibbles
Copy link
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 and state serialization
    • 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
  • 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
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

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
Contributor

github-actions bot commented Mar 14, 2026

rtibbles and others added 2 commits March 14, 2026 15:36
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>
@rtibbles rtibbles force-pushed the save_ka branch 2 times, most recently from 61dcb73 to fcc239b Compare March 16, 2026 00:03
Copy link
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.

rtibbles and others added 6 commits March 16, 2026 18:36
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>
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

Development

Successfully merging this pull request may close these issues.

1 participant