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
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/.
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
│ └── 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, 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": {
"type": "code",
"stringQuote": "\"",
"empty": {
"Number": { "shadow": "math_number", "fieldValues": { "NUM": "42" } },
"String": { "shadow": "text", "fieldValues": { "TEXT": "world" } }
}
}
},
"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" }
],
"highlighting": {
"python": { "keywords": ["print", "if", "for"], "strings": ["\"", "'"], "comment": "#" }
},
"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, empty?, stringQuote?, size? }.emptygives per-language defaults for empty value slots, keyed by the slot'scheck; each entry is{ 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));sizesets the display size for path-valuedimageelements.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 }).highlighting— optional per-element syntax-highlighting rules for the codespace/preview.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 definitionsData from "./definitions.json";
import { behaviors } from "./behaviors";
import { MorphicBlocks, type MorphicBlocksFormat } from "morphic-blocks";
// The whole definitions file is one argument. (A JSON import widens "code" to
// string, so one assertion to MorphicBlocksFormat is expected here.)
const definitions = definitionsData as unknown as MorphicBlocksFormat;
const engine = new MorphicBlocks(definitions, behaviors);
const workspace = engine.mount({
workspaceContainer: document.getElementById("workspace")!,
codespaceContainer: document.getElementById("codespace")!, // optional
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" }),
canvasToolbox: true, // use the custom HTML toolbox
blockly: { scrollbars: true, trashcan: true },
});
// Custom HTML toolbox (categories come from the definitions).
engine.mountToolbox(document.getElementById("toolbox")!);
// Editable text mirror of the workspace; receives drops, keyboard/gutter delete.
await engine.mountCodespace();
// Read-only preview of the preview mode's source element.
await engine.mountPreview(document.getElementById("preview")!);Modes, presets, categories, and highlighting 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
highlighting 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;
}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
highlightingrules, runtime-swapped onsetModes() - ✅ Multi-editor selection sync — block ↔ code editor ↔ codespace ↔ preview
- ✅ One-file constructor + mount-time validation + shipped JSON Schema (
$schema/version) - ✅ 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