dreamstack/examples/playground.html
enzotar 20ea2cb82e feat: Phase 5 — Live Playground with editor, preview, signal graph, console
- playground.html: full web IDE for DreamStack DSL
- Code editor with auto-compile (500ms debounce) + Ctrl+Enter
- Live preview: renders interactive UI from DreamStack code
- Signal graph: visualizes source signals, derived values, handlers, views
- Console: compile metrics, type inference (Signal<Int>, Derived<Bool>, etc.)
- 4 examples: counter, todo, effects, springs
- Dark theme with purple accent, premium glassmorphism design
2026-02-25 00:27:42 -08:00

1255 lines
No EOL
42 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack Playground</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface-2: #1c2333;
--border: #30363d;
--text: #e6edf3;
--text-dim: #7d8590;
--accent: #7c3aed;
--accent-glow: rgba(124, 58, 237, 0.3);
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--blue: #58a6ff;
--cyan: #39d353;
--pink: #f778ba;
--orange: #ffa657;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-sans: 'Inter', -apple-system, system-ui, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
height: 100vh;
overflow: hidden;
}
/* ─── Header ────────────────────────────────── */
.header {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
width: 28px;
height: 28px;
background: var(--accent);
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 14px;
box-shadow: 0 0 12px var(--accent-glow);
}
.header-title {
font-weight: 600;
font-size: 15px;
}
.header-title span {
color: var(--text-dim);
font-weight: 400;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-size: 13px;
font-family: var(--font-sans);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.btn:hover {
border-color: var(--accent);
background: rgba(124, 58, 237, 0.1);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
}
.btn-primary:hover {
background: #8b5cf6;
box-shadow: 0 0 16px var(--accent-glow);
}
.status-badge {
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.status-ok {
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.status-err {
background: rgba(248, 81, 73, 0.15);
color: var(--red);
}
/* ─── Main layout ──────────────────────────── */
.main {
height: calc(100vh - 48px);
display: grid;
grid-template-columns: 1fr 1fr;
}
.panel {
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel+.panel {
border-left: 1px solid var(--border);
}
.panel-header {
height: 36px;
display: flex;
align-items: center;
padding: 0 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 12px;
font-weight: 500;
color: var(--text-dim);
gap: 8px;
}
.panel-tab {
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.panel-tab:hover {
color: var(--text);
}
.panel-tab.active {
color: var(--text);
background: var(--surface-2);
}
.panel-body {
flex: 1;
overflow: auto;
}
/* ─── Code Editor ──────────────────────────── */
.editor {
width: 100%;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 13.5px;
line-height: 1.6;
padding: 16px;
border: none;
outline: none;
resize: none;
tab-size: 2;
white-space: pre;
overflow: auto;
}
/* ─── Preview ───────────────────────────────── */
.preview-container {
width: 100%;
height: 100%;
background: #fff;
position: relative;
}
.preview-container iframe {
width: 100%;
height: 100%;
border: none;
}
/* ─── Signal Graph ─────────────────────────── */
.signal-graph {
padding: 20px;
display: none;
}
.signal-graph.active {
display: block;
}
.signal-node {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 8px;
margin: 6px;
font-family: var(--font-mono);
font-size: 12px;
position: relative;
}
.signal-node.source {
background: rgba(124, 58, 237, 0.15);
border: 1px solid var(--accent);
color: #c4b5fd;
}
.signal-node.derived {
background: rgba(88, 166, 255, 0.15);
border: 1px solid var(--blue);
color: #93c5fd;
}
.signal-node.handler {
background: rgba(255, 166, 87, 0.15);
border: 1px solid var(--orange);
color: #fed7aa;
}
.signal-node.view-node {
background: rgba(57, 211, 83, 0.15);
border: 1px solid var(--cyan);
color: #86efac;
}
.type-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: var(--text-dim);
}
.graph-section {
margin-bottom: 20px;
}
.graph-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
margin-bottom: 8px;
padding-left: 4px;
}
.dep-arrow {
display: inline-flex;
align-items: center;
color: var(--text-dim);
font-size: 11px;
margin: 4px 8px;
font-family: var(--font-mono);
}
/* ─── Error Panel ──────────────────────────── */
.error-panel {
background: rgba(248, 81, 73, 0.05);
border-top: 1px solid rgba(248, 81, 73, 0.3);
padding: 12px 16px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--red);
max-height: 150px;
overflow: auto;
display: none;
}
.error-panel.active {
display: block;
}
.error-line {
padding: 2px 0;
}
.error-title {
font-weight: 600;
color: var(--red);
}
/* ─── Console ───────────────────────────────── */
.console-output {
background: var(--bg);
padding: 12px 16px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
overflow: auto;
height: 100%;
display: none;
}
.console-output.active {
display: block;
}
.console-entry {
padding: 2px 0;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
.console-entry .tag {
display: inline-block;
padding: 0 4px;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
margin-right: 6px;
}
.tag-signal {
background: rgba(124, 58, 237, 0.2);
color: #c4b5fd;
}
.tag-effect {
background: rgba(255, 166, 87, 0.2);
color: #fed7aa;
}
.tag-type {
background: rgba(88, 166, 255, 0.2);
color: #93c5fd;
}
.tag-info {
background: rgba(57, 211, 83, 0.2);
color: #86efac;
}
/* ─── Resize handle ────────────────────────── */
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.2s;
position: absolute;
top: 0;
bottom: 0;
z-index: 10;
}
.resize-handle:hover {
background: var(--accent);
}
/* ─── Example selector ─────────────────────── */
.example-select {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 12px;
font-family: var(--font-sans);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
}
.example-select:focus {
outline: 1px solid var(--accent);
}
/* ─── Animations ───────────────────────────── */
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 4px var(--accent-glow);
}
50% {
box-shadow: 0 0 16px var(--accent-glow);
}
}
.compiling .logo {
animation: pulse-glow 1s ease-in-out infinite;
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="header" id="header">
<div class="header-left">
<div class="logo">DS</div>
<div class="header-title">DreamStack <span>Playground</span></div>
</div>
<div class="header-actions">
<select class="example-select" id="exampleSelect">
<option value="counter">Counter</option>
<option value="todo">Todo List</option>
<option value="effects">Effects Demo</option>
<option value="springs">Spring Physics</option>
</select>
<div class="status-badge status-ok" id="statusBadge">● Ready</div>
<button class="btn btn-primary" id="runBtn">▶ Run</button>
</div>
</div>
<!-- MAIN -->
<div class="main" id="main">
<!-- LEFT PANEL: EDITOR -->
<div class="panel" id="leftPanel">
<div class="panel-header">
<span class="panel-tab active" data-tab="code">📝 Code</span>
</div>
<div class="panel-body">
<textarea class="editor" id="editor" spellcheck="false"></textarea>
</div>
<div class="error-panel" id="errorPanel"></div>
</div>
<!-- RIGHT PANEL: PREVIEW / GRAPH / CONSOLE -->
<div class="panel" id="rightPanel">
<div class="panel-header">
<span class="panel-tab active" data-tab="preview" id="tabPreview">👁 Preview</span>
<span class="panel-tab" data-tab="graph" id="tabGraph">🔗 Signal Graph</span>
<span class="panel-tab" data-tab="console" id="tabConsole">📋 Console</span>
</div>
<div class="panel-body" style="position:relative">
<div class="preview-container active" id="previewPane">
<iframe id="previewFrame" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="signal-graph" id="graphPane"></div>
<div class="console-output" id="consolePane"></div>
</div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// DreamStack Playground — Client-side Compiler + Runtime
// ═══════════════════════════════════════════════════════════
// ─── Examples ─────────────────────────────────────────────
const EXAMPLES = {
counter: `// DreamStack Counter
// Signals auto-track dependencies. No useState, no selectors.
let count = 0
let label = "DreamStack"
// Derived values: recompute automatically when dependencies change
let doubled = count * 2
let is_hot = count > 10
view main = column [
text label
text "Count: {count}"
text "Doubled: {doubled}"
when is_hot -> text "🔥 On fire!"
row [
button "-" { click: decrement }
button "+" { click: increment }
button "Reset" { click: reset }
]
]
on increment -> count += 1
on decrement -> count -= 1
on reset -> count = 0`,
todo: `// DreamStack Todo
// Reactive list rendering with signals — no virtual DOM.
let todos = []
let input_text = ""
let filter = "all"
// Derived: filtered todos
let visible = todos | filter_by(filter)
let total = todos.length
let done = todos | count_where(completed)
let active = total - done
view main = column [
text "📋 DreamStack Todos"
row [
input input_text { placeholder: "What needs doing?" }
button "Add" { click: add_todo }
]
list visible -> todo [
row [
button (if todo.completed "✅" else "⬜") { click: toggle(todo.id) }
text todo.text
button "✕" { click: remove(todo.id) }
]
]
row [
text "{active} remaining"
button "All" { click: set_filter("all") }
button "Active" { click: set_filter("active") }
button "Done" { click: set_filter("done") }
]
]
on add_todo -> {
todos = [...todos, { id: next_id(), text: input_text, completed: false }]
input_text = ""
}`,
effects: `// DreamStack Effects
// Algebraic effects for testable, composable side-effects.
effect Http.search(query: String): [Result]
effect Time.debounce(ms: Int): ()
let query = ""
let results = []
let loading = false
// Stream pipeline: debounce -> distinct -> search
let search_stream = stream from query
| debounce(250)
| distinct
| flat_map(q -> perform Http.search(q))
view main = column [
text "🔍 DreamStack Search"
input query { placeholder: "Search..." }
when loading -> text "Searching..."
list results -> item [
text item.title
text item.description
]
]
// Effect handler — swappable for testing!
handle search_stream {
Http.search(q) -> fetch("/api/search?q=" ++ q)
Time.debounce(ms) -> wait(ms)
}`,
springs: `// DreamStack Springs
// Physics-based animation — springs are signals!
let sidebar_w = spring(target: 240, stiffness: 170, damping: 26)
let card_y = spring(target: 0, stiffness: 300, damping: 30)
let expanded = true
// Derived: icon rotation follows sidebar state
let toggle_rotation = spring(target: 0, stiffness: 200, damping: 20)
view main = row [
// Sidebar
panel { width: sidebar_w } [
text "📊 Dashboard"
text "📈 Analytics"
text "⚡ Signals"
button "☰" { click: toggle_sidebar }
]
// Main area
column [
text "Welcome to DreamStack"
text "Springs are signals — they auto-propagate!"
row [
card { y: card_y } [
text "⚡ Signals: 1,247"
]
]
]
]
on toggle_sidebar -> {
expanded = !expanded
sidebar_w.target = if expanded 240 else 64
toggle_rotation.target = if expanded 0 else 180
}`
};
// ─── Mini Compiler (JS-side) ──────────────────────────────
class DSCompiler {
constructor() {
this.signals = [];
this.deriveds = [];
this.handlers = [];
this.views = [];
this.effects = [];
this.errors = [];
}
compile(source) {
this.signals = [];
this.deriveds = [];
this.handlers = [];
this.views = [];
this.effects = [];
this.errors = [];
const lines = source.split('\n');
let current_view = null;
let view_depth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const lineNum = i + 1;
if (line.startsWith('//') || line === '') continue;
// let NAME = VALUE
if (line.startsWith('let ')) {
const match = line.match(/^let\s+(\w+)\s*=\s*(.+)$/);
if (match) {
const [, name, value] = match;
const isLiteral = /^(\d+|"[^"]*"|true|false|\[\])$/.test(value.trim());
const isSpring = value.trim().startsWith('spring(');
const deps = this.extractDeps(value);
if (isSpring) {
this.signals.push({
name, value: value.trim(), type: 'spring',
springType: 'Spring<Float>', line: lineNum
});
} else if (isLiteral) {
const ty = this.inferType(value.trim());
this.signals.push({ name, value: value.trim(), type: 'source', inferredType: ty, line: lineNum });
} else {
const ty = this.inferExprType(value.trim());
this.deriveds.push({ name, expr: value.trim(), deps, type: 'derived', inferredType: ty, line: lineNum });
}
} else {
this.errors.push({ line: lineNum, message: `Syntax error: malformed 'let' declaration` });
}
}
// effect NAME(...)
else if (line.startsWith('effect ')) {
const match = line.match(/^effect\s+(\S+)\(([^)]*)\):\s*(.+)$/);
if (match) {
const [, name, params, retType] = match;
this.effects.push({
name, params: params.split(',').map(p => p.trim()).filter(Boolean),
returnType: retType.trim(), line: lineNum
});
}
}
// on NAME -> BODY
else if (line.startsWith('on ')) {
const match = line.match(/^on\s+(\w+)\s*->\s*(.*)$/);
if (match) {
const [, event, body] = match;
this.handlers.push({ event, body: body || '', line: lineNum });
}
}
// view NAME = ...
else if (line.startsWith('view ')) {
const match = line.match(/^view\s+(\w+)\s*=\s*(.*)$/);
if (match) {
const [, name, body] = match;
this.views.push({ name, body, line: lineNum });
}
}
}
// Validate: check for unbound variables in deriveds
const known = new Set([
...this.signals.map(s => s.name),
...this.deriveds.map(d => d.name),
]);
for (const d of this.deriveds) {
for (const dep of d.deps) {
if (!known.has(dep) && !['length', 'completed', 'filter_by', 'count_where'].includes(dep)) {
this.errors.push({
line: d.line,
message: `Unbound variable '${dep}' in derived expression '${d.name}'`
});
}
}
}
return {
signals: this.signals,
deriveds: this.deriveds,
handlers: this.handlers,
views: this.views,
effects: this.effects,
errors: this.errors,
};
}
extractDeps(expr) {
const ids = expr.match(/[a-zA-Z_]\w*/g) || [];
const keywords = new Set(['if', 'else', 'when', 'true', 'false', 'let', 'spring',
'target', 'stiffness', 'damping', 'mass', 'filter_by', 'count_where',
'debounce', 'distinct', 'flat_map', 'perform', 'stream', 'from', 'length',
'next_id', 'fetch', 'wait', 'not']);
return [...new Set(ids.filter(id => !keywords.has(id)))];
}
inferType(value) {
if (/^\d+$/.test(value)) return 'Signal<Int>';
if (/^\d+\.\d+$/.test(value)) return 'Signal<Float>';
if (/^".*"$/.test(value)) return 'Signal<String>';
if (value === 'true' || value === 'false') return 'Signal<Bool>';
if (value === '[]') return 'Signal<[?]>';
return 'Signal<?>';
}
inferExprType(expr) {
if (expr.includes('>') || expr.includes('<') || expr.includes('==')) return 'Derived<Bool>';
if (expr.includes('+') || expr.includes('-') || expr.includes('*') || expr.includes('/')) return 'Derived<Int>';
if (expr.includes('.length')) return 'Derived<Int>';
if (expr.includes('|')) return 'Derived<?>';
return 'Derived<?>';
}
}
// ─── Signal Graph Visualizer ──────────────────────────────
function renderSignalGraph(compiled) {
const graph = document.getElementById('graphPane');
let html = '';
// Sources
if (compiled.signals.length > 0) {
html += '<div class="graph-section">';
html += '<div class="graph-section-title">⚡ Source Signals</div>';
for (const s of compiled.signals) {
const ty = s.type === 'spring' ? s.springType : s.inferredType;
html += `<div class="signal-node source">
<strong>${s.name}</strong>
<span class="type-badge">${ty}</span>
<span style="color:var(--text-dim);font-size:11px">= ${escHtml(s.value)}</span>
</div>`;
}
html += '</div>';
}
// Derived
if (compiled.deriveds.length > 0) {
html += '<div class="graph-section">';
html += '<div class="graph-section-title">🔗 Derived Values</div>';
for (const d of compiled.deriveds) {
const depList = d.deps.join(', ');
html += `<div class="signal-node derived">
<strong>${d.name}</strong>
<span class="type-badge">${d.inferredType}</span>
<span class="dep-arrow">← ${depList || '(none)'}</span>
</div>`;
}
html += '</div>';
}
// Handlers
if (compiled.handlers.length > 0) {
html += '<div class="graph-section">';
html += '<div class="graph-section-title">🎯 Event Handlers</div>';
for (const h of compiled.handlers) {
html += `<div class="signal-node handler">
<strong>on ${h.event}</strong>
<span style="color:var(--text-dim);font-size:11px">→ ${escHtml(h.body).substring(0, 50)}</span>
</div>`;
}
html += '</div>';
}
// Effects
if (compiled.effects.length > 0) {
html += '<div class="graph-section">';
html += '<div class="graph-section-title">✨ Effect Declarations</div>';
for (const e of compiled.effects) {
html += `<div class="signal-node view-node">
<strong>${e.name}</strong>
<span class="type-badge">(${e.params.join(', ')}) → ${e.returnType}</span>
</div>`;
}
html += '</div>';
}
// Views
if (compiled.views.length > 0) {
html += '<div class="graph-section">';
html += '<div class="graph-section-title">👁 Views</div>';
for (const v of compiled.views) {
html += `<div class="signal-node view-node">
<strong>view ${v.name}</strong>
<span class="type-badge">View</span>
</div>`;
}
html += '</div>';
}
// Dependency graph (mini SVG)
if (compiled.deriveds.length > 0) {
html += '<div class="graph-section">';
html += '<div class="graph-section-title">📊 Dependency Flow</div>';
html += '<div style="padding:8px;font-family:var(--font-mono);font-size:12px;color:var(--text-dim)">';
for (const d of compiled.deriveds) {
for (const dep of d.deps) {
html += `<div style="padding:2px 0"><span style="color:var(--accent)">${dep}</span> → <span style="color:var(--blue)">${d.name}</span></div>`;
}
}
html += '</div>';
html += '</div>';
}
graph.innerHTML = html || '<div style="padding:20px;color:var(--text-dim)">Write some DreamStack code to see the signal graph</div>';
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ─── Console ──────────────────────────────────────────────
const consoleLogs = [];
function logToConsole(tag, message) {
consoleLogs.push({ tag, message, time: performance.now() });
renderConsole();
}
function renderConsole() {
const pane = document.getElementById('consolePane');
pane.innerHTML = consoleLogs.map(entry => {
const tagClass = entry.tag === 'signal' ? 'tag-signal'
: entry.tag === 'effect' ? 'tag-effect'
: entry.tag === 'type' ? 'tag-type'
: 'tag-info';
return `<div class="console-entry">
<span class="tag ${tagClass}">${entry.tag}</span>
${escHtml(entry.message)}
</div>`;
}).join('');
pane.scrollTop = pane.scrollHeight;
}
// ─── Preview Generation ───────────────────────────────────
function generatePreview(compiled) {
// Build a simple interactive preview from the compiled AST
const signals = compiled.signals;
const deriveds = compiled.deriveds;
const handlers = compiled.handlers;
let previewHtml = `<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.app {
background: rgba(255,255,255,0.95);
border-radius: 16px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
min-width: 320px;
max-width: 500px;
backdrop-filter: blur(20px);
}
h1 { font-size: 24px; margin-bottom: 16px; color: #1a1a2e; }
.signal-display {
background: #f0f0ff;
border-radius: 10px;
padding: 12px 16px;
margin: 8px 0;
font-size: 16px;
color: #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.signal-name { color: #7c3aed; font-weight: 600; font-family: monospace; }
.signal-value { color: #1a1a2e; font-weight: 700; font-size: 18px; }
.btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
button {
padding: 10px 20px;
border-radius: 8px;
border: none;
background: #7c3aed;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:hover { background: #6d28d9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(124,58,237,0.3); }
button:active { transform: translateY(0); }
.hot-indicator {
background: linear-gradient(135deg, #f59e0b, #ef4444);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
font-size: 18px;
padding: 8px 0;
display: none;
}
.derived-display {
background: #e8f4fd;
border-radius: 10px;
padding: 12px 16px;
margin: 8px 0;
font-size: 16px;
color: #0a5276;
}
.spring-demo {
background: #1a1a2e;
border-radius: 12px;
padding: 24px;
margin: 12px 0;
text-align: center;
color: white;
position: relative;
overflow: hidden;
height: 120px;
}
.spring-ball {
width: 40px; height: 40px;
background: #7c3aed;
border-radius: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
transition: left 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 0 20px rgba(124,58,237,0.5);
}
</style>
</head>
<body>
<div class="app">`;
// Find the view name
const viewName = compiled.views[0]?.name || 'main';
// Generate signal displays
const numSignals = signals.filter(s => s.type === 'source');
const springSignals = signals.filter(s => s.type === 'spring');
// Title from first string signal
const titleSignal = numSignals.find(s => s.inferredType === 'Signal<String>');
if (titleSignal) {
previewHtml += `<h1 id="title">${titleSignal.value.replace(/"/g, '')}</h1>`;
} else {
previewHtml += `<h1>DreamStack Preview</h1>`;
}
// Numeric signals
const intSignals = numSignals.filter(s => s.inferredType === 'Signal<Int>');
for (const s of intSignals) {
previewHtml += `<div class="signal-display">
<span class="signal-name">${s.name}</span>
<span class="signal-value" id="val_${s.name}">${s.value}</span>
</div>`;
}
// Derived displays
for (const d of deriveds) {
if (d.inferredType === 'Derived<Bool>') {
previewHtml += `<div class="hot-indicator" id="val_${d.name}">🔥 On fire!</div>`;
} else {
previewHtml += `<div class="derived-display">
<span class="signal-name">${d.name}</span> =
<span id="val_${d.name}">0</span>
</div>`;
}
}
// Spring demos
if (springSignals.length > 0) {
previewHtml += `<div class="spring-demo" id="springDemo">
<div class="spring-ball" id="springBall" style="left:50%"></div>
</div>`;
}
// Handler buttons
if (handlers.length > 0) {
previewHtml += `<div class="btn-row">`;
for (const h of handlers) {
const label = h.event.replace(/_/g, ' ');
previewHtml += `<button onclick="${h.event}()">${label}</button>`;
}
previewHtml += `</div>`;
}
previewHtml += `</div>
<script>
// ═══ DreamStack Runtime (embedded) ═══
const _signals = {};
const _effects = [];
function signal(name, initial) {
_signals[name] = { value: initial, subs: [] };
return _signals[name];
}
function update() {
// Update derived values
${deriveds.map(d => {
if (d.inferredType.includes('Bool')) {
return `{
const val = ${d.expr.replace(/(\w+)/g, (m) => {
const found = numSignals.find(s => s.name === m);
return found ? `_signals["${m}"].value` : m;
})};
const el = document.getElementById("val_${d.name}");
if (el) el.style.display = val ? "block" : "none";
}`;
} else {
return `{
try {
const val = ${d.expr.replace(/(\b[a-zA-Z_]\w*\b)/g, (m) => {
const isKnown = [...numSignals, ...deriveds].find(x => x.name === m);
return isKnown ? `_signals["${m}"]?.value || 0` : m;
})};
_signals["${d.name}"] = _signals["${d.name}"] || { value: 0 };
_signals["${d.name}"].value = val;
const el = document.getElementById("val_${d.name}");
if (el) el.textContent = val;
} catch(e) {}
}`;
}
}).join('\n ')}
// Update signal displays
${intSignals.map(s => `{
const el = document.getElementById("val_${s.name}");
if (el) el.textContent = _signals["${s.name}"].value;
}`).join('\n ')}
}
// Init signals
${numSignals.map(s => `signal("${s.name}", ${s.value});`).join('\n')}
${deriveds.map(d => `_signals["${d.name}"] = { value: 0 };`).join('\n')}
// Handler functions
${handlers.map(h => {
// Parse simple handler bodies
const body = h.body.trim();
let jsBody = '';
if (body.includes('+=')) {
const [varname, delta] = body.split('+=').map(s => s.trim());
jsBody = `_signals["${varname}"].value += ${delta}; update();`;
} else if (body.includes('-=')) {
const [varname, delta] = body.split('-=').map(s => s.trim());
jsBody = `_signals["${varname}"].value -= ${delta}; update();`;
} else if (body.includes('= ')) {
const [varname, val] = body.split('=').map(s => s.trim());
jsBody = `_signals["${varname}"].value = ${val}; update();`;
} else if (body.includes('!expanded')) {
jsBody = `
const ball = document.getElementById("springBall");
if (ball) {
const current = parseInt(ball.style.left) || 50;
ball.style.left = current > 40 ? "15%" : "85%";
}
`;
} else {
jsBody = `console.log("${h.event} triggered"); update();`;
}
return `function ${h.event}() { ${jsBody} }`;
}).join('\n')}
// Spring interaction
${springSignals.length > 0 ? `
document.getElementById("springDemo")?.addEventListener("click", (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width * 100);
const ball = document.getElementById("springBall");
if (ball) ball.style.left = x + "%";
});
` : ''}
// Initial render
update();
<\/script>
</body>
</html>`;
return previewHtml;
}
// ─── App Logic ────────────────────────────────────────────
const compiler = new DSCompiler();
const editor = document.getElementById('editor');
const previewFrame = document.getElementById('previewFrame');
const statusBadge = document.getElementById('statusBadge');
const errorPanel = document.getElementById('errorPanel');
const runBtn = document.getElementById('runBtn');
const exampleSelect = document.getElementById('exampleSelect');
// Tab switching
const rightTabs = document.querySelectorAll('#rightPanel .panel-tab');
rightTabs.forEach(tab => {
tab.addEventListener('click', () => {
rightTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const name = tab.dataset.tab;
document.getElementById('previewPane').style.display = name === 'preview' ? 'block' : 'none';
document.getElementById('graphPane').style.display = name === 'graph' ? 'block' : 'none';
document.getElementById('consolePane').style.display = name === 'console' ? 'block' : 'none';
// Add/remove .active class
document.getElementById('previewPane').className = name === 'preview' ? 'preview-container active' : 'preview-container';
document.getElementById('graphPane').className = name === 'graph' ? 'signal-graph active' : 'signal-graph';
document.getElementById('consolePane').className = name === 'console' ? 'console-output active' : 'console-output';
});
});
function runCode() {
const source = editor.value;
const startTime = performance.now();
// Compile
const compiled = compiler.compile(source);
const compileTime = (performance.now() - startTime).toFixed(1);
// Log to console
consoleLogs.length = 0;
logToConsole('info', `Compiled in ${compileTime}ms`);
logToConsole('signal', `${compiled.signals.length} source signal(s)`);
logToConsole('type', `${compiled.deriveds.length} derived value(s)`);
logToConsole('effect', `${compiled.effects.length} effect declaration(s)`);
for (const s of compiled.signals) {
const ty = s.type === 'spring' ? s.springType : s.inferredType;
logToConsole('type', `${s.name}: ${ty}`);
}
for (const d of compiled.deriveds) {
logToConsole('type', `${d.name}: ${d.inferredType} ← [${d.deps.join(', ')}]`);
}
// Errors
if (compiled.errors.length > 0) {
statusBadge.className = 'status-badge status-err';
statusBadge.textContent = `${compiled.errors.length} error(s)`;
errorPanel.className = 'error-panel active';
errorPanel.innerHTML = compiled.errors.map(e =>
`<div class="error-line"><span class="error-title">── ERROR ─────</span> Line ${e.line}: ${escHtml(e.message)}</div>`
).join('');
for (const e of compiled.errors) {
logToConsole('type', `ERROR line ${e.line}: ${e.message}`);
}
} else {
statusBadge.className = 'status-badge status-ok';
statusBadge.textContent = `${compileTime}ms`;
errorPanel.className = 'error-panel';
errorPanel.innerHTML = '';
}
// Update signal graph
renderSignalGraph(compiled);
// Generate and display preview
const previewHtml = generatePreview(compiled);
const blob = new Blob([previewHtml], { type: 'text/html' });
previewFrame.src = URL.createObjectURL(blob);
}
// Run on button click
runBtn.addEventListener('click', runCode);
// Auto-run on Ctrl+Enter
editor.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
runCode();
}
// Tab key inserts spaces
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
const end = editor.selectionEnd;
editor.value = editor.value.substring(0, start) + ' ' + editor.value.substring(end);
editor.selectionStart = editor.selectionEnd = start + 2;
}
});
// Example selector
exampleSelect.addEventListener('change', () => {
editor.value = EXAMPLES[exampleSelect.value];
runCode();
});
// Auto-compile with debounce
let compileTimer = null;
editor.addEventListener('input', () => {
clearTimeout(compileTimer);
compileTimer = setTimeout(runCode, 500);
});
// Initialize with counter example
editor.value = EXAMPLES.counter;
runCode();
</script>
</body>
</html>