Files
ritz/examples/runtime/ritz-runtime.js
T
Aaron SinclairandClaude ea7dadc5ae Implement self-hosted debug console and @external decorator
This commit completes the self-hosting of Ritz's debug console and
introduces the @external decorator for elegant JavaScript bridge functions.

## Major Features

### 1. Self-Hosted Debug Console (DebugConsole.py)
- Entire debug console UI written in Ritz itself
- Signal-based state management for toggle functionality
- Displays runtime stats (memory, signals, DOM nodes, logs)
- Live reactive updates when signals change in main app
- Keyboard shortcut support (Ctrl+`)

### 2. @external Decorator for JavaScript Bridges
- Pythonic decorator syntax: @external or @bridge
- Parser automatically recognizes and tracks external functions
- Codegen emits proper LLVM declarations
- No more hacky string checks (startswith("js_"))
- Clean, declarative bridge function definitions

Example:
```python
@external
def js_toggle_debug_console():
    pass

def toggle():
    js_toggle_debug_console()  # Clean call to JS bridge
```

### 3. Enhanced RitzLoader Debug Mode
- `new RitzLoader({ debug: true })` auto-loads DebugConsole.wasm
- Keyboard shortcut registration (Ctrl+` toggle)
- Runtime stats injection (memory, signals, DOM nodes)
- Automatic status element hiding when debug enabled
- Debug console isolated in separate WASM runtime

## Bug Fixes

- Fixed reactive text node scoping (prevent global name collisions)
- Fixed CSS specificity for debug console status (use #ritz-debug)
- Fixed signal cross-contamination between runtimes (scope to rootElement)
- Fixed parser to handle expression statements in function bodies
- Fixed conditional branch rendering (append children to wrapper divs)

## Infrastructure Improvements

- Added H4 and Pre elements to syntax.py
- Parser handles @external decorator on top-level functions
- Codegen pre-collects external functions before emitting declarations
- Runtime scopes querySelector to prevent cross-runtime interference
- Debug stats update reactively on every signal change

## Files Added/Modified

New files:
- examples/runtime/DebugConsole.py - Self-hosted debug UI
- examples/runtime/DebugConsole.wasm - Compiled debug console (1.8KB)
- PHASE4_CLEANUP.md - Next phase planning document

Modified:
- ritz/parser.py - @external decorator support
- ritz/llvm_codegen_signals.py - External function handling
- ritz/syntax.py - Added external/bridge decorators, H4, Pre
- examples/runtime/ritz-runtime.js - Debug mode, stats updates
- examples/runtime/styles.css - Merged debug styles
- examples/template.html - Debug mode enabled by default
- examples/highfive/HighFive.html - Simplified using template

## Known Issues (Addressed in PHASE4_CLEANUP.md)

1. Debug console shifts page RIGHT (should shift LEFT)
2. Toggle implementation un-Ritzy (calls JS to manipulate DOM)
   - Should use reactive class binding + @event decorator
   - Needs event object bridge for keyboard events
3. Missing test infrastructure

## Next Steps (Phase 4)

See PHASE4_CLEANUP.md for detailed plan:
- Implement reactive class binding (signal-driven classes)
- Add @event decorator for declarative event registration
- Build event object bridge (access event.key, event.ctrlKey)
- Create Python test mode (execute without browser)
- Add comprehensive test suite
- Update all examples to use new patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 18:28:24 -08:00

545 lines
20 KiB
JavaScript

/**
* Ritz Runtime - WebAssembly DOM Bridge
*
* Provides the runtime environment for Ritz components compiled to WASM.
*/
class RitzRuntime {
constructor(rootElement) {
this.rootElement = rootElement || document.getElementById('app');
this.nodes = new Map();
this.nextNodeId = 1;
this.log = [];
this.wasmInstance = null;
this.debugEnabled = true;
}
// ===== DOM Manipulation Bridge =====
js_create_element(tagPtr) {
const tag = this.readString(tagPtr);
const element = document.createElement(tag);
const id = this.nextNodeId++;
this.nodes.set(id, element);
this._log(`createElement("${tag}") = ${id}`);
return id;
}
js_create_text(textPtr) {
const text = this.readString(textPtr);
const textNode = document.createTextNode(text);
const id = this.nextNodeId++;
this.nodes.set(id, textNode);
this._log(`createTextNode("${text}") = ${id}`);
return id;
}
js_append_child(parentId, childId) {
const parent = this.nodes.get(parentId);
const child = this.nodes.get(childId);
if (parent && child) {
parent.appendChild(child);
this._log(`appendChild(${parentId}, ${childId})`);
}
}
js_set_attribute(elemId, namePtr, valuePtr) {
const element = this.nodes.get(elemId);
const name = this.readString(namePtr);
const value = this.readString(valuePtr);
if (element) {
if (name === 'class') {
element.className = value;
} else {
element.setAttribute(name, value);
}
this._log(`setAttribute(${elemId}, "${name}", "${value}")`);
}
}
js_bind_event(elemId, eventNamePtr, handlerNamePtr) {
const element = this.nodes.get(elemId);
const eventName = this.readString(eventNamePtr);
const handlerName = this.readString(handlerNamePtr);
if (element && this.wasmInstance) {
const handler = this.wasmInstance.exports[handlerName];
if (handler) {
element.addEventListener(eventName, () => {
this._log(`[Event] ${eventName} → ${handlerName}()`);
handler();
this.updateSignals();
});
this._log(`bindEvent(${elemId}, "${eventName}", "${handlerName}")`);
} else {
console.error(`Handler ${handlerName} not found in WASM exports`);
}
}
}
js_toggle_debug_console() {
// Bridge function to toggle debug console from WASM
if (window.ritzLoader) {
window.ritzLoader.toggleDebugConsole();
}
this._log('[Debug] Toggle debug console from WASM');
}
js_set_root(elemId) {
const element = this.nodes.get(elemId);
if (element) {
this.rootElement.innerHTML = '';
this.rootElement.appendChild(element);
this._log(`setRoot(${elemId})`);
}
}
js_update_text_content(nodeId, textPtr) {
const node = this.nodes.get(nodeId);
const text = this.readString(textPtr);
if (node) {
node.textContent = text;
this._log(`updateTextContent(${nodeId}, "${text}")`);
}
}
js_create_text_with_int(prefixPtr, intValue) {
const prefix = this.readString(prefixPtr);
const text = prefix + intValue.toString();
const textNode = document.createTextNode(text);
const id = this.nextNodeId++;
this.nodes.set(id, textNode);
this._log(`createTextWithInt("${prefix}", ${intValue}) = "${text}" (id=${id})`);
return id;
}
js_update_text_with_int(nodeId, prefixPtr, intValue) {
const node = this.nodes.get(nodeId);
const prefix = this.readString(prefixPtr);
const text = prefix + intValue.toString();
if (node) {
node.textContent = text;
this._log(`updateTextWithInt(${nodeId}, "${prefix}", ${intValue}) = "${text}"`);
}
}
// ===== Signal Management =====
updateSignals() {
// Discover and update all signals
if (!this.wasmInstance) return;
const exports = this.wasmInstance.exports;
// Find all get_* functions (signal getters)
for (const [name, fn] of Object.entries(exports)) {
if (name.startsWith('get_') && typeof fn === 'function') {
const signalName = name.substring(4); // Remove 'get_' prefix
const value = fn();
this._log(`[Signal] ${signalName} = ${value}`);
// Update DOM elements bound to this signal
// TODO: Track signal-to-DOM bindings during render for efficiency
this.updateSignalDisplay(signalName, value);
}
}
// Re-evaluate all reactive conditionals
for (const [name, fn] of Object.entries(exports)) {
if (name.startsWith('reevaluate_conditional_') && typeof fn === 'function') {
fn();
this._log(`[Reactive] ${name}()`);
}
}
// Re-evaluate all reactive match statements
for (const [name, fn] of Object.entries(exports)) {
if (name.startsWith('reevaluate_match_') && typeof fn === 'function') {
fn();
this._log(`[Reactive] ${name}()`);
}
}
// Update debug console stats if debug mode is enabled
if (window.ritzLoader && this.debugEnabled) {
window.ritzLoader.updateDebugStats();
}
}
updateSignalDisplay(signalName, value) {
// Find DOM elements that should display this signal
// IMPORTANT: Only search within this runtime's root element to avoid cross-contamination
const selectors = [
`[data-signal="${signalName}"]`,
`.${signalName}`,
'.count', // Hardcoded for demo - TODO: track bindings properly
];
selectors.forEach(selector => {
// Only query within this runtime's root element, not the entire document
const elements = this.rootElement.querySelectorAll(selector);
elements.forEach(el => {
// Check if text contains the signal name or looks like a counter
const text = el.textContent;
if (text.includes(signalName) || text.match(/\d+/)) {
// Replace the number in the text
el.textContent = text.replace(/\d+/, value);
}
});
});
}
// ===== Reactive Conditional Rendering =====
js_create_marker() {
// Create a comment node as a marker for conditional insertion
const marker = document.createComment('conditional-marker');
const id = this.nextNodeId++;
this.nodes.set(id, marker);
this._log(`Created marker node ${id}`);
return id;
}
js_replace_at_marker(markerId, oldNodeId, newNodeId) {
// Replace DOM node at marker position
const marker = this.nodes.get(markerId);
if (!marker) {
console.error(`[replace_at_marker] Marker ${markerId} not found`);
return;
}
const parent = marker.parentNode;
if (!parent) {
console.error(`[replace_at_marker] Marker ${markerId} has no parent`);
return;
}
this._log(`[replace_at_marker] marker=${markerId}, old=${oldNodeId}, new=${newNodeId}`);
// Remove old node if it exists (0 means no old node to remove)
if (oldNodeId !== 0) {
const oldNode = this.nodes.get(oldNodeId);
if (!oldNode) {
console.warn(`[replace_at_marker] Old node ${oldNodeId} not found in registry`);
} else if (!oldNode.parentNode) {
console.warn(`[replace_at_marker] Old node ${oldNodeId} not in DOM`);
} else if (oldNode.parentNode !== parent) {
console.warn(`[replace_at_marker] Old node ${oldNodeId} in wrong parent`);
oldNode.parentNode.removeChild(oldNode);
this._log(`[replace_at_marker] Removed ${oldNodeId} from wrong parent`);
} else {
// Remove from correct parent
parent.removeChild(oldNode);
this._log(`[replace_at_marker] Removed ${oldNodeId} from correct parent`);
}
}
// Insert new node after marker (only if newNodeId is not 0)
if (newNodeId !== 0) {
const newNode = this.nodes.get(newNodeId);
if (!newNode) {
console.error(`[replace_at_marker] New node ${newNodeId} not found`);
return;
}
// Insert right after the marker
// If newNode is already in DOM elsewhere, this will move it
const nextSibling = marker.nextSibling;
if (nextSibling) {
parent.insertBefore(newNode, nextSibling);
this._log(`[replace_at_marker] Inserted ${newNodeId} before sibling`);
} else {
parent.appendChild(newNode);
this._log(`[replace_at_marker] Appended ${newNodeId} (no sibling)`);
}
}
}
// ===== Memory & String Utilities =====
readString(ptr) {
if (!this.memory) return '';
const bytes = new Uint8Array(this.memory.buffer, ptr);
let length = 0;
while (bytes[length] !== 0) length++;
return new TextDecoder().decode(bytes.slice(0, length));
}
setMemory(memory) {
this.memory = memory;
}
setWasmInstance(instance) {
this.wasmInstance = instance;
}
// ===== Logging & Debug =====
_log(message) {
if (this.debugEnabled) {
this.log.push(message);
}
}
getLog() {
return this.log;
}
clearLog() {
this.log = [];
}
}
/**
* RitzLoader - Simplified WASM loading
*/
class RitzLoader {
constructor(options = {}) {
this.rootElement = options.rootElement || document.getElementById('app');
this.statusElement = options.statusElement || document.getElementById('status');
this.debug = options.debug !== undefined ? options.debug : false;
this.onLoad = options.onLoad || (() => {});
this.onError = options.onError || ((err) => console.error(err));
this.debugConsoleRuntime = null;
}
async load(wasmPath, componentName = null, props = {}) {
try {
// Auto-detect component name from wasm filename if not provided
if (!componentName) {
// Extract from path: "Main.wasm" → "Main", "path/to/Main.wasm" → "Main"
componentName = wasmPath.split('/').pop().replace('.wasm', '');
}
this.setStatus('⏳ Creating runtime...');
const runtime = new RitzRuntime(this.rootElement);
this.setStatus(`⏳ Loading ${wasmPath}...`);
const response = await fetch(wasmPath);
if (!response.ok) {
throw new Error(`Failed to load ${wasmPath}: ${response.status}`);
}
const wasmBytes = await response.arrayBuffer();
this.setStatus('⏳ Instantiating WASM...');
// Create imports for WASM
const imports = {
env: {
js_create_element: (ptr) => runtime.js_create_element(ptr),
js_create_text: (ptr) => runtime.js_create_text(ptr),
js_create_text_with_int: (p, i) => runtime.js_create_text_with_int(p, i),
js_append_child: (p, c) => runtime.js_append_child(p, c),
js_set_attribute: (e, n, v) => runtime.js_set_attribute(e, n, v),
js_set_root: (e) => runtime.js_set_root(e),
js_bind_event: (e, n, h) => runtime.js_bind_event(e, n, h),
js_update_text_content: (n, t) => runtime.js_update_text_content(n, t),
js_update_text_with_int: (n, p, i) => runtime.js_update_text_with_int(n, p, i),
js_create_marker: () => runtime.js_create_marker(),
js_replace_at_marker: (m, o, n) => runtime.js_replace_at_marker(m, o, n),
}
};
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
// Setup runtime
runtime.setMemory(instance.exports.memory);
runtime.setWasmInstance(instance);
this.setStatus(`⏳ Rendering ${componentName}...`);
// Call the main entry point (which renders top-level component and sets root)
const mainFn = instance.exports.main;
if (!mainFn) {
throw new Error(`main function not found in WASM exports`);
}
mainFn();
// Update signals
runtime.updateSignals();
this.setStatus(`✅ ${componentName} loaded!`, 'success');
// Make runtime globally available for debugging
window.ritzRuntime = runtime;
window.ritzInstance = instance;
this.onLoad(runtime, instance);
// Load debug console if debug mode is enabled
if (this.debug) {
await this.loadDebugConsole(componentName);
}
return { runtime, instance };
} catch (error) {
this.setStatus(`❌ Error: ${error.message}`, 'error');
this.onError(error);
throw error;
}
}
async loadDebugConsole(appComponentName) {
try {
console.log('[Debug] Loading debug console...');
// Create a container for the debug console
const debugContainer = document.createElement('div');
debugContainer.id = 'debug-console-root';
document.body.appendChild(debugContainer);
console.log('[Debug] Created container:', debugContainer);
// Create debug console runtime
const debugRuntime = new RitzRuntime(debugContainer);
debugRuntime.debugEnabled = false; // Don't log debug console's own operations
// Load DebugConsole.wasm from runtime directory
const debugWasmPath = '../runtime/DebugConsole.wasm';
console.log('[Debug] Fetching:', debugWasmPath);
const response = await fetch(debugWasmPath);
if (!response.ok) {
console.warn('Could not load debug console:', response.status);
return;
}
console.log('[Debug] DebugConsole.wasm loaded successfully');
const wasmBytes = await response.arrayBuffer();
// Create imports for debug console WASM
const imports = {
env: {
js_create_element: (ptr) => debugRuntime.js_create_element(ptr),
js_create_text: (ptr) => debugRuntime.js_create_text(ptr),
js_create_text_with_int: (p, i) => debugRuntime.js_create_text_with_int(p, i),
js_append_child: (p, c) => debugRuntime.js_append_child(p, c),
js_set_attribute: (e, n, v) => debugRuntime.js_set_attribute(e, n, v),
js_set_root: (e) => debugRuntime.js_set_root(e),
js_bind_event: (e, n, h) => debugRuntime.js_bind_event(e, n, h),
js_update_text_content: (n, t) => debugRuntime.js_update_text_content(n, t),
js_update_text_with_int: (n, p, i) => debugRuntime.js_update_text_with_int(n, p, i),
js_create_marker: () => debugRuntime.js_create_marker(),
js_replace_at_marker: (m, o, n) => debugRuntime.js_replace_at_marker(m, o, n),
js_toggle_debug_console: () => debugRuntime.js_toggle_debug_console(),
}
};
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
// Setup debug runtime
debugRuntime.setMemory(instance.exports.memory);
debugRuntime.setWasmInstance(instance);
// Render debug console
console.log('[Debug] Rendering debug console...');
instance.exports.main();
debugRuntime.updateSignals();
this.debugConsoleRuntime = debugRuntime;
console.log('[Debug] Debug console rendered. Checking DOM...');
const debugElement = document.querySelector('.ritz-debug');
console.log('[Debug] Debug element:', debugElement);
console.log('[Debug] Debug element classes:', debugElement?.className);
// Hide the loader status element (we have our own in debug console)
if (this.statusElement) {
this.statusElement.style.display = 'none';
}
// Populate runtime stats
this.updateDebugStats(appComponentName);
// Setup keyboard shortcut (Ctrl+`)
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === '`') {
e.preventDefault();
console.log('[Debug] Toggle debug console');
this.toggleDebugConsole();
}
});
// Make debug console and loader globally available
window.ritzDebugConsole = debugRuntime;
window.ritzLoader = this;
console.log('🎭 Ritz Debug Console loaded! Press Ctrl+` to toggle.');
} catch (error) {
console.warn('Failed to load debug console:', error);
}
}
toggleDebugConsole() {
// Toggle the debug console by toggling body class
// The Ritz debug console component manages its own visibility via signal
// But we need to trigger the body class for layout
document.body.classList.toggle('debug-open');
// Find the debug console element and toggle its hidden class
const debugElement = document.querySelector('.ritz-debug');
if (debugElement) {
debugElement.classList.toggle('hidden');
}
}
updateDebugStats(componentName = 'Main') {
// Update status with component name (inside debug console)
const statusEl = document.querySelector('#ritz-debug .status');
if (statusEl) {
statusEl.textContent = `✅ ${componentName} loaded!`;
statusEl.className = 'status success';
}
// Update component section
const componentPre = document.querySelector('#ritz-debug .debug-section:nth-child(1) pre');
if (componentPre && window.ritzInstance) {
const wasmSize = window.ritzInstance.exports.memory.buffer.byteLength;
componentPre.textContent = `${componentName}.wasm (${Math.round(wasmSize / 1024)}KB)`;
}
// Update runtime stats
const statsPre = document.querySelector('#ritz-debug .debug-section:nth-child(3) pre');
if (statsPre && window.ritzRuntime && window.ritzInstance) {
const memory = window.ritzInstance.exports.memory.buffer.byteLength;
const memoryKB = Math.round(memory / 1024);
const memoryMB = (memory / 1024 / 1024).toFixed(2);
const nodeCount = window.ritzRuntime.nodes.size;
// Count signals (globals that start with @signal_)
const exports = Object.keys(window.ritzInstance.exports);
const signalCount = exports.filter(name =>
name.startsWith('get_') || name.startsWith('set_')
).length / 2;
statsPre.textContent =
`Memory: ${memoryKB}KB (${memoryMB}MB)\n` +
`Signals: ${signalCount} reactive\n` +
`DOM nodes: ${nodeCount} cached\n` +
`Log entries: ${window.ritzRuntime.log.length}`;
}
}
setStatus(message, type = '') {
if (this.statusElement) {
this.statusElement.textContent = message;
this.statusElement.className = 'status ' + type;
}
}
}
// Export for use in modules or global scope
if (typeof module !== 'undefined' && module.exports) {
module.exports = { RitzRuntime, RitzLoader };
} else {
window.RitzRuntime = RitzRuntime;
window.RitzLoader = RitzLoader;
}