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>
This commit is contained in:
Aaron SinclairandClaude committed 2025-11-15 18:28:24 -08:00
1 parent ddd2c03a9d
commit ea7dadc5ae
20 files changed
+1324 -70

No files matched your search

+166 -2
View File
@@ -77,6 +77,14 @@ class RitzRuntime {
}
}
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) {
@@ -151,11 +159,16 @@ class RitzRuntime {
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
// For now, update any element with class matching signal name or data-signal attribute
// IMPORTANT: Only search within this runtime's root element to avoid cross-contamination
const selectors = [
`[data-signal="${signalName}"]`,
`.${signalName}`,
@@ -163,7 +176,8 @@ class RitzRuntime {
];
selectors.forEach(selector => {
const elements = document.querySelectorAll(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;
@@ -284,8 +298,10 @@ 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 = {}) {
@@ -353,6 +369,11 @@ class RitzLoader {
this.onLoad(runtime, instance);
// Load debug console if debug mode is enabled
if (this.debug) {
await this.loadDebugConsole(componentName);
}
return { runtime, instance };
} catch (error) {
@@ -362,6 +383,149 @@ class RitzLoader {
}
}
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;