From db566e1f9d9707f163ae3c314b879245c2222725 Mon Sep 17 00:00:00 2001 From: henz Date: Thu, 6 Aug 2026 20:21:07 +0800 Subject: [PATCH 1/2] feat(stepper): render inline thumbnails for opaque values (DrRacket style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `image` SyntaxTemplatePart (renders a node's data-URL property as an inline , falling back to nothing when absent, like `child`/`list`) and an `unless` part (the inverse of `when`, for pairing an image with a textual fallback) to common-stepper. web-stepper renders both generically. A language opts in by using them in its SyntaxProfile templates — see the companion py-slang PR, which wires this up for opaque module values (e.g. a `rune` Rune) that attach a stepper-thumbnail render hook. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0193EaXSjQ3fGYJGCU1uDvmD --- .changeset/stepper-opaque-thumbnail.md | 6 ++++++ src/common/stepper/src/index.ts | 13 ++++++++++++- src/web/stepper/src/SubstVisualizer.tsx | 19 +++++++++++++++++++ src/web/stepper/src/styles.ts | 10 ++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .changeset/stepper-opaque-thumbnail.md diff --git a/.changeset/stepper-opaque-thumbnail.md b/.changeset/stepper-opaque-thumbnail.md new file mode 100644 index 0000000..f782af7 --- /dev/null +++ b/.changeset/stepper-opaque-thumbnail.md @@ -0,0 +1,6 @@ +--- +"@sourceacademy/common-stepper": patch +"@sourceacademy/web-stepper": patch +--- + +Add an `image` `SyntaxTemplatePart` (renders a node's data-URL property as an inline ``, e.g. a rendered thumbnail for an opaque runtime value — DrRacket-style — falling back to nothing when the property is absent) and an `unless` part (the inverse of `when`, for pairing an image with a textual fallback). `web-stepper` renders both generically; a language opts in by using them in its `SyntaxProfile` templates. Both additions are optional and backward-compatible. diff --git a/src/common/stepper/src/index.ts b/src/common/stepper/src/index.ts index d7e5f42..0fb7ea3 100644 --- a/src/common/stepper/src/index.ts +++ b/src/common/stepper/src/index.ts @@ -118,6 +118,15 @@ export type StepperTokenClass = "operator" | "identifier" | "literal" | "conditi * - `{ block }` — render the `node[block]` array as an indented suite (one statement per line). * - `{ lines }` — render the `node[lines]` array one-per-line without extra indentation (the root). * - `{ when, parts }` — render `parts` only when `node[when]` is present (e.g. an optional `else`). + * - `{ unless, parts }` — render `parts` only when `node[when]` is absent/falsy; the inverse of + * `when`, for an "otherwise" branch (e.g. a value that renders one way when a field is present and + * another way when it isn't — see `image` below). + * - `{ image, altProp?, cls? }` — render `node[image]` (expected to be a data URL, e.g. + * `"data:image/png;base64,..."`) as an inline ``; renders nothing when the property is + * absent/falsy, exactly like `child`/`list` do for a missing value. `altProp` optionally names + * another (possibly dotted) node property to use as the image's `alt`/`title` text. Lets a + * language display an opaque runtime value as a small picture inline in a step — e.g. a rendered + * thumbnail a module attaches to a graphics object — DrRacket-style, rather than only ever as text. */ export type SyntaxTemplatePart = | string @@ -127,7 +136,9 @@ export type SyntaxTemplatePart = | { list: string; sep: string; prefix?: string; cls?: StepperTokenClass } | { block: string } | { lines: string } - | { when: string; parts: SyntaxTemplatePart[] }; + | { when: string; parts: SyntaxTemplatePart[] } + | { unless: string; parts: SyntaxTemplatePart[] } + | { image: string; altProp?: string; cls?: StepperTokenClass }; /** * Declares a node type as a "function value" in the substitution model and where to read its name. diff --git a/src/web/stepper/src/SubstVisualizer.tsx b/src/web/stepper/src/SubstVisualizer.tsx index 3513a43..a353a71 100644 --- a/src/web/stepper/src/SubstVisualizer.tsx +++ b/src/web/stepper/src/SubstVisualizer.tsx @@ -834,6 +834,25 @@ function renderNode( {part.parts.map((p, i) => renderPart(p, i))} ) : null; } + if ("unless" in part) { + return node[part.unless] ? null : ( + {part.parts.map((p, i) => renderPart(p, i))} + ); + } + if ("image" in part) { + const src = readNodeProp(node, part.image); + if (typeof src !== "string" || src === "") return null; + const alt = part.altProp === undefined ? undefined : readNodeProp(node, part.altProp); + return ( + {alt + ); + } return null; }; return {template.map((part, i) => renderPart(part, i))}; diff --git a/src/web/stepper/src/styles.ts b/src/web/stepper/src/styles.ts index 8f7046a..801e73f 100644 --- a/src/web/stepper/src/styles.ts +++ b/src/web/stepper/src/styles.ts @@ -52,6 +52,16 @@ const STEPPER_CSS = ` font: 16px/normal 'Inconsolata', 'Consolas', monospace; } +/* An opaque value's rendered thumbnail (see the image SyntaxTemplatePart) — shown inline at text + * height, DrRacket-style, rather than expanding the line. */ +.sa-substituter .stepper-opaque-thumbnail, +.stepper-popover .stepper-opaque-thumbnail { + height: 1.4em; + width: auto; + vertical-align: middle; + border-radius: 3px; +} + .sa-substituter .stepper-mu-term, .stepper-popover .stepper-mu-term { font-weight: bold; From cab24aaae9a77b9bf30795d53d030b0045eade0b Mon Sep 17 00:00:00 2001 From: henz Date: Thu, 6 Aug 2026 21:32:20 +0800 Subject: [PATCH 2/2] fix(stepper): address review on opaque-thumbnail image/unless parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix `unless` doc comment, which referenced `node[when]` instead of `node[unless]`. - Enforce that the `image` part's src is actually a `data:` URL before rendering it, instead of trusting any string — a live network URL there would let a render silently leak the viewer's IP/referrer to a third-party host. - Drop the no-op `@sourceacademy/web-stepper` patch bump from the changeset: that package is in .changeset/config.json's `ignore` list (bundled host plugin, not independently published), matching how prior stepper-only changes (e.g. #49) only bumped common-stepper. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017rkUEePRmhJmjB3whkhU6s --- .changeset/stepper-opaque-thumbnail.md | 1 - src/common/stepper/src/index.ts | 15 ++++++++------- src/web/stepper/src/SubstVisualizer.tsx | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/stepper-opaque-thumbnail.md b/.changeset/stepper-opaque-thumbnail.md index f782af7..28e7c55 100644 --- a/.changeset/stepper-opaque-thumbnail.md +++ b/.changeset/stepper-opaque-thumbnail.md @@ -1,6 +1,5 @@ --- "@sourceacademy/common-stepper": patch -"@sourceacademy/web-stepper": patch --- Add an `image` `SyntaxTemplatePart` (renders a node's data-URL property as an inline ``, e.g. a rendered thumbnail for an opaque runtime value — DrRacket-style — falling back to nothing when the property is absent) and an `unless` part (the inverse of `when`, for pairing an image with a textual fallback). `web-stepper` renders both generically; a language opts in by using them in its `SyntaxProfile` templates. Both additions are optional and backward-compatible. diff --git a/src/common/stepper/src/index.ts b/src/common/stepper/src/index.ts index 0fb7ea3..63f1050 100644 --- a/src/common/stepper/src/index.ts +++ b/src/common/stepper/src/index.ts @@ -118,15 +118,16 @@ export type StepperTokenClass = "operator" | "identifier" | "literal" | "conditi * - `{ block }` — render the `node[block]` array as an indented suite (one statement per line). * - `{ lines }` — render the `node[lines]` array one-per-line without extra indentation (the root). * - `{ when, parts }` — render `parts` only when `node[when]` is present (e.g. an optional `else`). - * - `{ unless, parts }` — render `parts` only when `node[when]` is absent/falsy; the inverse of + * - `{ unless, parts }` — render `parts` only when `node[unless]` is absent/falsy; the inverse of * `when`, for an "otherwise" branch (e.g. a value that renders one way when a field is present and * another way when it isn't — see `image` below). - * - `{ image, altProp?, cls? }` — render `node[image]` (expected to be a data URL, e.g. - * `"data:image/png;base64,..."`) as an inline ``; renders nothing when the property is - * absent/falsy, exactly like `child`/`list` do for a missing value. `altProp` optionally names - * another (possibly dotted) node property to use as the image's `alt`/`title` text. Lets a - * language display an opaque runtime value as a small picture inline in a step — e.g. a rendered - * thumbnail a module attaches to a graphics object — DrRacket-style, rather than only ever as text. + * - `{ image, altProp?, cls? }` — render `node[image]` as an inline ``; renders nothing when + * the property is absent/falsy, or when it isn't a `data:` URL (e.g. `"data:image/png;base64,..."`) + * — only self-contained data URLs are rendered, never a live network URL, so a module can't turn a + * stepper render into a request to an arbitrary host. `altProp` optionally names another (possibly + * dotted) node property to use as the image's `alt`/`title` text. Lets a language display an opaque + * runtime value as a small picture inline in a step — e.g. a rendered thumbnail a module attaches + * to a graphics object — DrRacket-style, rather than only ever as text. */ export type SyntaxTemplatePart = | string diff --git a/src/web/stepper/src/SubstVisualizer.tsx b/src/web/stepper/src/SubstVisualizer.tsx index a353a71..ea574fb 100644 --- a/src/web/stepper/src/SubstVisualizer.tsx +++ b/src/web/stepper/src/SubstVisualizer.tsx @@ -841,7 +841,7 @@ function renderNode( } if ("image" in part) { const src = readNodeProp(node, part.image); - if (typeof src !== "string" || src === "") return null; + if (typeof src !== "string" || !src.startsWith("data:")) return null; const alt = part.altProp === undefined ? undefined : readNodeProp(node, part.altProp); return (