diff --git a/.changeset/stepper-opaque-thumbnail.md b/.changeset/stepper-opaque-thumbnail.md
new file mode 100644
index 0000000..28e7c55
--- /dev/null
+++ b/.changeset/stepper-opaque-thumbnail.md
@@ -0,0 +1,5 @@
+---
+"@sourceacademy/common-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..63f1050 100644
--- a/src/common/stepper/src/index.ts
+++ b/src/common/stepper/src/index.ts
@@ -118,6 +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[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]` 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
@@ -127,7 +137,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..ea574fb 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.startsWith("data:")) return null;
+ const alt = part.altProp === undefined ? undefined : readNodeProp(node, part.altProp);
+ return (
+
+ );
+ }
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;