A TypeScript framework built on Google Blockly for rendering blocks in multiple modes — enabling smooth transitions between block-based and text-based programming representations.
Each block has named elements (visual parts). A global elementTypes registry declares what each element name represents. Modes declare which elements are visible; presets assign a mode to each view (toolbox, workspace, codespace, preview). CSS defines how modes look.
iconic mode → icon + title (compact visual)
lexical mode → natural language block template
syntactic mode → code-syntax block template
code modes → codespace (text editor) mirrors the workspace
The framework makes no external requests: it never contacts another server, so the embedding site decides every request its visitors make. Blockly's own images and sounds load from blockly-media/ on your site, copied there by morphic-blocks copy-media (see Example Usage).
Workspace and toolbox can run in different modes simultaneously. Dragging from the toolbox to the workspace — or dropping onto the codespace — adds a block to the underlying model.
The primary motivating use case is gradual block-to-text programming transition — progressively fading visual scaffolding as learners build fluency. Because the mode system decouples content from presentation, the same architecture supports several other applications without framework changes (only new modes, elements, and CSS):
- Localization / internationalization — same blocks with different natural-language labels per mode (English, German, Spanish, …).
- Accessibility — alternate modes for high-contrast, large-text, dyslexia-friendly, or screen-reader-optimized rendering.
- Age-appropriate rendering — icon-centric for young learners; verbose-text for older learners.
- Comparative programming education — render the same program in Python, Java, and C++ syntax side-by-side.
- Expert vs novice views — compact (icon) for experts, verbose (full-text / descriptions) for novices.
- Domain-specific visual languages — custom modes for music, robotics, data science, game design.
- Documentation-enriched blocks — render descriptions, examples, or rationale alongside blocks.
These are potential applications, not currently deployed. They illustrate that the framework's value extends beyond the transition use case.
| Type | What it means |
|---|---|
text |
Plain label / description (HTML text on tiles, never on workspace blocks) |
code |
Blockly template with %N / %FIELDNAME placeholders |
image |
SVG or image (used inside block templates as FieldImage, or as an icon) |
Element names are free-form — icon, title, concept, python, javascript etc. are conventions. The elementTypes registry maps names to types.
An image value can be either a bare file path ("icons/log.svg" — auto-wrapped as <img>, sized via the element's size config, default 16×16) or explicit <img> HTML ("<img src='icons/log.svg'>"). Both work.
bun installbun run devbun run buildLibrary output is emitted to packages/morphic-blocks/dist/.
bun run testRuns the framework tests with Vitest in
jsdom, a browser stand-in, since Blockly needs
a DOM. Tests live in packages/morphic-blocks/test/; setup-dom.ts answers the
few text measuring calls jsdom cannot, because only generated text is tested,
not rendering.
morphic-blocks/
├── apps/
│ └── sandbox/ # Dev app: preset-driven view configurations
│ └── src/
│ ├── definitions.json # elementTypes, modes, presets, blocks
│ ├── behaviors.ts # executable code generators
│ ├── main.ts
│ └── modes/ # One CSS file per mode
├── packages/
│ └── morphic-blocks/ # Core library
│ ├── definitions.schema.json # JSON Schema for definitions files
│ ├── vitest.config.ts # Test runner config (jsdom)
│ ├── test/ # Framework tests + DOM setup
│ └── src/morphic/
│ ├── MorphicBlocks.ts # Orchestration (mount, setModes, applyPreset, codegen)
│ ├── block-view.ts # Block rendering + mode class decoration + fields
│ ├── block-namespace.ts # Clean ↔ morphic: Blockly-type translation
│ ├── view-resolver.ts # Mode / source-element resolution
│ ├── template.ts # Template parsing (%N, %FIELDNAME, <img>)
│ ├── template-codegen.ts # Text rendering from templates (codespace/preview)
│ ├── code-editor.ts # CodeMirror wrapper (codespace/preview/code editor)
│ ├── codegen.ts # JavaScript code generation (behaviors)
│ ├── element-types.ts # elementTypes helpers (type, size, code settings, empty defaults)
│ ├── validate-definitions.ts# Mount-time definitions validation
│ ├── syntax-highlight.ts # Definition-driven highlighting (CodeMirror)
│ ├── toolbox-canvas.ts # Custom HTML toolbox (drag source)
│ ├── selection-sync.ts # Block ↔ code line highlighting
│ ├── styles.ts # CSS loading + mode coverage validation
│ └── types.ts # All TypeScript types
└── CLAUDE.md
One JSON document describes everything a block is. Point $schema at the
shipped schema for editor autocomplete and inline validation.
{
"$schema": "./node_modules/morphic-blocks/definitions.schema.json",
"version": 1,
"elementTypes": {
"icon": "image",
"title": "text",
"description":"text",
"concept": "code",
"python": "code"
},
"code": {
"python": {
"stringQuote": "\"",
"empty": {
"Number": { "shadow": "math_number", "fieldValues": { "NUM": "42" } },
"String": { "shadow": "text", "fieldValues": { "TEXT": "world" } }
},
"highlighting": { "keywords": ["print", "if", "for"], "strings": ["\"", "'"], "comment": "#" }
}
},
"modes": [
{ "name": "iconic", "elements": ["icon", "title", "description"] },
{ "name": "conceptual","elements": ["title", "concept"] },
{ "name": "syntax-py", "elements": ["title", "python"] }
],
"presets": [
{ "name": "iconic", "label": "Iconic", "toolbox": "iconic", "workspace": "conceptual" },
{ "name": "hybrid", "label": "Hybrid", "toolbox": "conceptual",
"workspace": "conceptual", "codespace": "syntax-py", "preview": "syntax-py" }
],
"categories": [
{ "name": "Output", "color": "#5C81A6" }
],
"blocks": [
{
"identifier": "text_print",
"category": "Output",
"elements": {
"title": "Print",
"description":"Prints a value to the console",
"concept": "Output %1",
"python": "print(%1)"
},
"inputSlots": {
"1": { "kind": "value", "name": "TEXT", "check": "String" }
}
},
{
"identifier": "math_arithmetic",
"category": "Operations",
"elements": { "python": "%1 %OP %2" },
"inputSlots": {
"1": { "kind": "value", "name": "A", "check": "Number" },
"2": { "kind": "value", "name": "B", "check": "Number" }
},
"fields": {
"OP": { "type": "dropdown", "options": ["+", ["-", "−"], ["*", "×"], ["/", "÷"]], "default": "+" }
},
"output": true
}
]
}$schema/version— optional.$schema(relative path or URL) gives editors autocomplete + validation;versionmarks the format revision. Both are ignored at runtime.elementTypes— global registry mapping element names to a bare type string ("text" | "code" | "image") or a config object{ type, size? };sizesets the display size for path-valuedimageelements.code— how each code element's language is written, keyed by element name:emptygives defaults for empty value slots, keyed by the slot'scheck, each{ shadow?, placeholder?, fieldValues? }(ashadowis a ghosted, auto-restored block; aplaceholderis a real, deletable one — placeholder wins when both are set);stringQuotewraps framework-supplied literals inStringslots (print("hello")notprint(hello));emptyStatementis written into empty statement slots where the language needs one ("pass"for Python);highlightingcolors the codespace/preview showing that element;valuesmakes printed values read like that language (True,None,['a']) while it is shown.modes— mode definitions ({ name, elements });elementsrender on the toolbox tile in list order.presets— named per-view mode configurations (toolbox / workspace / codespace / preview).categories— optional toolbox groupings ({ name, color }).blocks— flat array of block definitions.
A block declares inline widgets in a fields map, keyed by the %FIELDNAME token in its templates:
| Type | Config |
|---|---|
dropdown |
options (below), default (selected value) |
text |
default |
number |
default, min, max, precision |
checkbox |
default (boolean) |
A dropdown option is "==" (value = label), ["/", "÷"] ([value, label]), or { "value": "/", "label": "÷" }. The value is generated and shown in text views; the optional label is a block-only display override (so a block can show ÷ while the code says /). Fields outside these four (variable, colour, custom) are attached by a behavior's onViewApplied — an undeclared %FIELDNAME token is left for the behavior to fill.
When a value slot is empty, the workspace shows an empty socket and the codespace shows a bracketed type marker ([NUMBER], [TEXT], [BOOL]) unless an empty default supplies a value.
Block identifiers are free-form. You can name a block anything — including names that match Blockly's built-in block types such as logic_boolean or math_number. The framework registers each block internally as morphic:<identifier>, so your identifiers never clobber Blockly's stock blocks (which the framework still relies on for shadows, placeholders, and connection checks).
You always work with the clean identifier — it keys the behaviors map, the per-block default.shadow / default.placeholder references, and toolbox block lists. A reference in those places resolves to your block when it matches one of your definitions, and to a Blockly stock type otherwise. So "shadow": "math_number" keeps using Blockly's number block, while "shadow": "my_number" uses yours if you define a block with that identifier.
| Field | Required | Purpose |
|---|---|---|
name |
yes | Mode identifier (keep distinct from element / preset names) |
elements |
yes | Element names rendered on the toolbox tile, in list order |
A mode is purely presentational. Its source element — what a codespace or preview renders when the mode is assigned to it — is the first type: "code" element in its elements array. How a code element renders on a toolbox tile (block vs text) is set by the preset's toolbox entry, so one mode is reusable across presets.
A preset assigns a mode to each view and drives which views are visible:
| Field | Required | Purpose |
|---|---|---|
name |
yes | Preset identifier |
label |
no | Display label (falls back to name) |
toolbox |
yes | Toolbox mode (see below) |
workspace |
no* | Mode for the block workspace |
codespace |
no* | Mode whose source element the codespace renders |
preview |
no | Mode whose source element the read-only preview renders |
toolbox is either a mode name (all code elements render as blocks) or an object { mode, render } where render is a per-element map { name: "block" | "text" } controlling how each code element renders on the tile (e.g. { "mode": "python", "render": { "python": "text" } } shows the code as source text). Elements not named in render default to block, and render affects the toolbox tile only.
* at least one of workspace / codespace must be set. Presence of a view key means the view is shown; workspace and codespace can be visible at the same time with different modes.
import definitions from "./definitions.json";
import { behaviors } from "./behaviors";
import { MorphicBlocks } from "morphic-blocks";
// The whole definitions file is one argument, passed as imported; mount()
// validates it.
const engine = new MorphicBlocks(definitions, behaviors);
// One call sets up every view it gets a container for. The workspace is ready
// at once; awaiting waits for the text editors, which load in the background.
await engine.mount({
workspaceContainer: document.getElementById("workspace")!,
toolboxContainer: document.getElementById("toolbox")!, // custom HTML toolbox
codespaceContainer: document.getElementById("codespace")!, // editable text view
previewContainer: document.getElementById("preview")!, // read-only preview
preset: "iconic", // initial preset (by name)
onPresetApplied(preset) {
// show/hide panes based on which view keys the preset uses
},
modesFolder: import.meta.glob("./modes/*.css", { eager: true, query: "?url" }),
blockly: { scrollbars: true, trashcan: true },
});Blockly loads its images and sounds from blockly-media/ next to the page.
morphic-blocks copy-media public/blockly-media copies them there before dev
and build, as the sandbox does; blockly.media points elsewhere.
Every container is optional. Selection sync links the views by default
(selectionSync: false turns it off), and a codeEditorContainer adds the
generated JavaScript view, hidden until engine.showCodeEditor(). The separate
mountToolbox(), mountCodespace(), mountPreview(), mountCodeEditor() and
mountToolbar() methods remain for setting a view up later.
More views beside those, each in its own mode, come from engine.addView():
previews, codespaces, and read only workspaces that mirror the program as
blocks. A preset sets their modes by name with views, and a toolbar attaches
to any view by name:
engine.addView({ kind: "preview", name: "right", mode: "syntax-js", container: rightEl,
toolbar: { container: rightToolbarEl } });Modes, presets, categories, and code settings all come from the definitions
passed to the constructor — mount() only takes runtime wiring.
Switch presets or modes at runtime:
engine.applyPreset("hybrid");
engine.setModes({ workspaceMode: "conceptual", codespaceMode: "syntax-py" });Generate JavaScript from the workspace (via behaviors):
const js = engine.generateJavaScript();One behavior function per block, producing the executable code string for the
Run path. Declared fields and code templates handle display, so a
field-only block needs no behavior; behaviors are for execution and for custom
(onViewApplied) fields.
import type { MorphicBehaviorMap } from "morphic-blocks";
export const behaviors: MorphicBehaviorMap = {
text_print(proxy) {
return `console.log(${proxy.inputs.TEXT ?? "undefined"});\n`;
},
};The codespace and preview don't use behaviors — they render the type: "code"
element content directly (with %N / %FIELDNAME substitution).
Templates use %N for input slots, %FIELDNAME for fields, and <img> tags for images:
| Syntax | Result |
|---|---|
%1, %2 |
Input slot (Blockly input + text substitution for codespace/preview) |
%FIELDNAME |
Inline field declared in fields (e.g. %NUM, %OP), or filled by onViewApplied |
<img …> |
Image (Blockly FieldImage on block templates) |
| Plain text | Becomes a Blockly label field |
Whitespace and indentation in text rendering: preserved as authored. "if ( %1 ) {\n %2\n}" produces multi-line output; "if ( %1 ) { %2 }" stays on one line.
mount() validates the definitions and reports problems that would otherwise
fail silently at render time — throwing on structural breakage (mismatched %N
sets across a block's code elements, a %FIELDNAME with no field and no
behavior, an unresolvable shadow / placeholder) and warning on degraded
config (a %N with no inputSlots entry, an undeclared element name, a
code key that isn't a code element, a config field on the wrong
element type, a name reused across element / mode / preset). Call the exported
validateDefinitions(...) to check a file before mounting.
One CSS file per mode. Use .morphic-mode-{name} to target blocks in a specific mode, and .morphic-workspace-root.morphic-mode-{name} to target the Blockly workspace.
/* syntax-py.css */
.morphic-workspace-root.morphic-mode-syntax-py .blocklyText {
font-family: "Fira Code", monospace;
}Hand the stylesheets to mount() in whatever form your bundler produces. Each
value may be a link to the file or the CSS itself:
// a whole folder, each file styling the mode it is named after (Vite)
modesFolder: import.meta.glob("./modes/*.css", { eager: true, query: "?url" }),
// or mode by mode, e.g. from a text import or a public URL
modeStyles: { py: pyCss, js: "/modes/js.css" },Block colours can be driven from CSS via a custom property:
.morphic-block-text_print {
--morphic-block-color: #b469d6;
}- ✅ Codespace — editable-by-structure text mirror of the block model
- ✅ Preview editor — read-only view of another mode's source element
- ✅ Drag from toolbox or grip handle (
⠿) into the codespace, with drop-position indicator - ✅ Slot-based drops — into empty
for/ifbodies, before/after siblings, and into empty value slots; reorder via grip - ✅ Keyboard + gutter
✕deletion - ✅ Declarable inline fields (
fieldsmap: dropdown / text / number / checkbox) — field-only blocks need no behavior for display - ✅ Inline field edits for atomic placeholders (text / number / dropdown), shadow auto-materialises on first edit
- ✅ Per-element empty-slot defaults; empty slots render a
[TYPE]marker when no default is set - ✅ Definition-driven syntax highlighting — per-element
code.<element>.highlightingrules, runtime-swapped onsetModes(); code shown as text on toolbox tiles is coloured the same way (toolbox.highlight) - ✅ Toolbox without a mouse — tiles drag with a finger or pen (
toolbox.touch) and are reached with Tab and added with Enter or Space, named for screen readers (toolbox.keyboard); both on by default - ✅ Multi-editor selection sync — block ↔ code editor ↔ codespace ↔ preview
- ✅ One-file constructor + mount-time validation + shipped JSON Schema (
$schema/version) - ✅ Added views —
engine.addView()for more previews, codespaces and read only workspaces, each in its own mode; presets name them withviews, toolbars attach by view name - ✅ Per-mode field rendering — a dropdown option's
displaymap (keyed by element name) makes the shown text mode-aware (True/true,and/&&) while the stored value drives execution and codegen
- Natural-language / locale layer — translate human-readable text (option labels like
Monday/Montag, descriptions, domain labels, string literals) across natural languages, orthogonal to the representation modes; likely an external translation table keyed by locale, merged at load. Separate from per-mode field rendering (the representation axis) - Editor toolbar for the codespace / preview
- Use empty defaults in the Blockly block view as well (cosmetic)
- Inline-edit coverage for
FieldVariable/FieldCheckboxand a protocol for plugin / developer fields - Bidirectional sync — AST parsing converts text back to blocks (future)
- Package architecture refactor — split framework into plugin/feature modules, once the feature surface stabilises