Skip to content

feat(rune): add stepper-thumbnail render hook to opaque Rune values - #890

Merged
martin-henz merged 5 commits into
masterfrom
feature/rune-thumbnail-stepper-hook
Aug 7, 2026
Merged

feat(rune): add stepper-thumbnail render hook to opaque Rune values#890
martin-henz merged 5 commits into
masterfrom
feature/rune-thumbnail-stepper-hook

Conversation

@martin-henz

Copy link
Copy Markdown
Member

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 in lib/modules-lib/src/conductor/thumbnail.ts so other modules (e.g. curve) can adopt the same convention later without importing from rune.
  • attachThumbnailHook (src/bundles/rune/src/rune.ts, wired in at __makeRune) mutates the Rune instance in place via a non-enumerable Object.defineProperty - a copy/wrapper would silently break every downstream instanceof Rune check that later transforms (scale, stack, overlay, ...) rely on.
  • renderRuneThumbnail renders via OffscreenCanvas + the existing DrawnNormalRune draw path (getWebGlFromCanvas/drawRunesToFrameBuffer widened to accept HTMLCanvasElement | OffscreenCanvas, no shader/buffer logic duplicated). OffscreenCanvas is the only rendering surface available in the module plugin's realm (a Web Worker - no document/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_SIZE is 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_anaglyph are explicitly out of scope here - they wrap pre-serialized frame data, not a live Rune, and "which frame to thumbnail" needs its own design decision as a follow-up.
  • New docs page (docs/src/modules/5-advanced/conductor-interop/6-opaque-thumbnails.md, linked from the existing 3-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 stepper import support (py-slang#385) landing first.

Test plan

  • yarn workspace @sourceacademy/modules-lib tsc/lint/test pass
  • yarn workspace @sourceacademy/bundle-rune tsc/lint/test pass (3 new tests: hook attached + non-enumerable when OffscreenCanvas is available, hook absent + no throw when unavailable, a failed render resolves to undefined rather than throwing)
  • yarn workspace @sourceacademy/tab-Rune tsc/lint and its real-WebGL Playwright test suite pass - confirms the widened draw()/getWebGlFromCanvas types don't break the tab's existing live rendering
  • Docs site builds cleanly with the new page
  • Manually rendered heart, circle, and stack(red(heart), circle) through the actual hook in headless Chromium (real OffscreenCanvas + 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

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
@martin-henz

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Rune opaque thumbnails

Layer / File(s) Summary
Thumbnail hook contract
lib/modules-lib/src/conductor/thumbnail.ts, lib/modules-lib/src/conductor/__tests__/thumbnail.test.ts, docs/src/modules/5-advanced/conductor-interop/*
Adds the shared RENDER_THUMBNAIL_SYMBOL and documents the optional opaque-value thumbnail contract.
Rune thumbnail rendering
src/bundles/rune/src/rune.ts, src/bundles/rune/src/functions.ts, src/bundles/rune/src/runes_webgl.ts
Adds OffscreenCanvas-compatible drawing and failure-safe PNG data URL rendering.
Rune integration and validation
src/bundles/rune/src/index.ts, src/bundles/rune/src/__tests__/index.test.ts
Attaches thumbnail hooks before opaque conversion and tests conditional attachment, non-enumerability, cleanup, and failures.

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
Loading

Possibly related issues

Suggested reviewers: leeyi45

Poem

A rabbit draws on canvas bright,
In offscreen hops beyond the light.
A symbol hides the tiny art,
PNG crumbs from Rune’s clever heart.
If render fails, it whispers: none.
Then bounds away when work is done.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the implementation, motivation, testing, documentation, and scope, although it does not reproduce all template checklist fields.
Title check ✅ Passed The title clearly and concisely identifies the Rune stepper-thumbnail hook added to opaque values.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/rune-thumbnail-stepper-hook

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 049a34b and 4902b29.

📒 Files selected for processing (9)
  • docs/src/modules/5-advanced/conductor-interop/3-opaque.md
  • docs/src/modules/5-advanced/conductor-interop/6-opaque-thumbnails.md
  • lib/modules-lib/src/conductor/__tests__/thumbnail.test.ts
  • lib/modules-lib/src/conductor/thumbnail.ts
  • src/bundles/rune/src/__tests__/index.test.ts
  • src/bundles/rune/src/functions.ts
  • src/bundles/rune/src/index.ts
  • src/bundles/rune/src/rune.ts
  • src/bundles/rune/src/runes_webgl.ts

Comment thread lib/modules-lib/src/conductor/thumbnail.ts Outdated
Comment thread src/bundles/rune/src/__tests__/index.test.ts
martin-henz and others added 4 commits August 6, 2026 21:27
- 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
@martin-henz
martin-henz merged commit e1be829 into master Aug 7, 2026
43 checks passed
@martin-henz
martin-henz deleted the feature/rune-thumbnail-stepper-hook branch August 7, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant