feat(rune): add stepper-thumbnail render hook to opaque Rune values - #890
Conversation
Rune's opaque value now carries an optional stepper-thumbnail renderer, per the design agreed in #879: a Symbol-keyed, zero-argument function attached directly to the JS object passed to opaque_make, invisible to student code and to the module's public exports. - RENDER_THUMBNAIL_SYMBOL (Symbol.for('source-academy.stepper.renderThumbnail')) lives in modules-lib so other modules (e.g. curve) can adopt the same convention later without importing from rune. - attachThumbnailHook (rune.ts) mutates the Rune instance in place via a non-enumerable Object.defineProperty - a copy/wrapper would break every downstream `instanceof Rune` check that later transforms rely on. - renderRuneThumbnail renders via OffscreenCanvas + the existing DrawnNormalRune draw path (getWebGlFromCanvas/drawRunesToFrameBuffer widened to accept HTMLCanvasElement | OffscreenCanvas, no shader logic duplicated) - OffscreenCanvas is the only rendering surface available in the module plugin's realm (a Web Worker, no DOM), and isn't universally supported, so the hook is only attached when actually usable and never throws on a failed render. - THUMBNAIL_SIZE is a single exported constant (32px) for easy retuning. - animate_rune/animate_anaglyph are explicitly out of scope - they wrap pre-serialized frame data, not a live Rune, and "which frame to thumbnail" needs its own design decision. This ships with no visible effect on its own - the stepper-side consumer lives in py-slang, is tracked there separately, and is itself gated on stepper `import` support (py-slang#385). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BGtXu5xNkraFeG1BjSnLW
|
@coderabbitai review |
✅ Action performedReview finished.
|
WalkthroughChangesRune opaque thumbnails
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant RuneModulePlugin
participant attachThumbnailHook
participant OffscreenCanvas
participant DrawnNormalRune
RuneModulePlugin->>attachThumbnailHook: wrap Rune before opaque_make
attachThumbnailHook->>OffscreenCanvas: create 32x32 canvas
OffscreenCanvas->>DrawnNormalRune: render Rune
DrawnNormalRune-->>attachThumbnailHook: canvas output
attachThumbnailHook-->>RuneModulePlugin: PNG data URL or undefined
Possibly related issues
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/modules-lib/src/conductor/thumbnail.ts`:
- Around line 15-19: Update the thumbnail-hook contract to consistently
represent render failure as undefined: in
lib/modules-lib/src/conductor/thumbnail.ts lines 15-19, state that failed
rendering returns or resolves to undefined; in
docs/src/modules/5-advanced/conductor-interop/6-opaque-thumbnails.md lines 19
and 34-35, change the result types to Promise<string | undefined> and () =>
Promise<string | undefined>, and require consumers to handle undefined.
In `@src/bundles/rune/src/__tests__/index.test.ts`:
- Around line 164-170: Extend the afterEach cleanup to save and restore the
property descriptor for RENDER_THUMBNAIL_SYMBOL on every shared RuneFunctions
instance, alongside the existing OffscreenCanvas restoration. Ensure descriptors
are captured before plugin.initialise() mutates the instances and restored or
removed as appropriate so tests cannot leak thumbnail hooks.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1c009383-3d02-4b6d-ae68-59a7d0dfce93
📒 Files selected for processing (9)
docs/src/modules/5-advanced/conductor-interop/3-opaque.mddocs/src/modules/5-advanced/conductor-interop/6-opaque-thumbnails.mdlib/modules-lib/src/conductor/__tests__/thumbnail.test.tslib/modules-lib/src/conductor/thumbnail.tssrc/bundles/rune/src/__tests__/index.test.tssrc/bundles/rune/src/functions.tssrc/bundles/rune/src/index.tssrc/bundles/rune/src/rune.tssrc/bundles/rune/src/runes_webgl.ts
- Contract docs (thumbnail.ts JSDoc, 6-opaque-thumbnails.md) declared the hook as () => Promise<string>, but the actual implementation resolves undefined on a failed render (and the docs said so a few lines later, contradicting the signature above it). State () => Promise<string | undefined> consistently and require callers to handle undefined, not just success. - plugin.initialise() attaches the hook directly onto RuneFunctions' shared, module-level Rune singletons (blank, circle, etc.), not a per-test copy. The thumbnail hook tests' afterEach only restored OffscreenCanvas, so a run that attached the hook left it on those singletons for the rest of the test file - harmless with the current declaration order, but a latent test-isolation bug. Strip the symbol back off every RuneFunctions value in afterEach so cleanup no longer depends on test order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BGtXu5xNkraFeG1BjSnLW
…tests utilities.test.ts (via utilities.ts -> errors.ts) pulls in js-slang/dist/errors/base, errors/rttcErrors, utils/operators, and utils/rttc, none of which were in optimizeDeps.include. With a cold Vite cache (as in CI), these get discovered mid-suite instead of pre-bundled, forcing a live dependency-optimizer reload - which was crashing utilities.test.ts's `/* @vitest browser: false */` override with "Cannot read properties of undefined (reading 'config')" on ubuntu-latest. Pre-bundling them avoids the mid-run reload entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018mNjABo3YLgMJc1GS2DcpC
Summary
Implements the
rune-module side of #879: Rune's opaque value now optionally carries its own stepper-thumbnail renderer, so a future py-slang stepper can show a small inline picture instead of<opaque>text at each step - without any change to the module's public/student-facing API.RENDER_THUMBNAIL_SYMBOL(Symbol.for('source-academy.stepper.renderThumbnail')) lives inlib/modules-lib/src/conductor/thumbnail.tsso other modules (e.g.curve) can adopt the same convention later without importing fromrune.attachThumbnailHook(src/bundles/rune/src/rune.ts, wired in at__makeRune) mutates theRuneinstance in place via a non-enumerableObject.defineProperty- a copy/wrapper would silently break every downstreaminstanceof Runecheck that later transforms (scale,stack,overlay, ...) rely on.renderRuneThumbnailrenders viaOffscreenCanvas+ the existingDrawnNormalRunedraw path (getWebGlFromCanvas/drawRunesToFrameBufferwidened to acceptHTMLCanvasElement | OffscreenCanvas, no shader/buffer logic duplicated).OffscreenCanvasis the only rendering surface available in the module plugin's realm (a Web Worker - nodocument/DOM there), and isn't universally supported, so the hook is only attached when actually usable, and never throws on a failed render - a bad thumbnail must never surface as a runtime error to student code.THUMBNAIL_SIZEis a single exported constant (32px, chosen against the stepper's actual 16px monospace/~20px-line-height text scale) for easy retuning later.animate_rune/animate_anaglyphare explicitly out of scope here - they wrap pre-serialized frame data, not a liveRune, and "which frame to thumbnail" needs its own design decision as a follow-up.docs/src/modules/5-advanced/conductor-interop/6-opaque-thumbnails.md, linked from the existing3-opaque.md) documents the convention for future module authors.This ships with no visible effect on its own. The stepper-side consumer (actually reading this property and rendering an
<img>) lives in py-slang, is tracked there separately, and is itself gated on stepperimportsupport (py-slang#385) landing first.Test plan
yarn workspace @sourceacademy/modules-lib tsc/lint/testpassyarn workspace @sourceacademy/bundle-rune tsc/lint/testpass (3 new tests: hook attached + non-enumerable whenOffscreenCanvasis available, hook absent + no throw when unavailable, a failed render resolves toundefinedrather than throwing)yarn workspace @sourceacademy/tab-Rune tsc/lintand its real-WebGL Playwright test suite pass - confirms the wideneddraw()/getWebGlFromCanvastypes don't break the tab's existing live renderingheart,circle, andstack(red(heart), circle)through the actual hook in headless Chromium (realOffscreenCanvas+ WebGL +convertToBlob) and confirmed valid, non-blank 32x32 PNGs come out the other end - not just a type-checked no-op🤖 Generated with Claude Code
https://claude.ai/code/session_018BGtXu5xNkraFeG1BjSnLW