feat: TodoMVC example with full reactivity

Full todo app using DreamStack's signal runtime:
- Add/toggle/remove individual todos
- Filter by all/active/completed
- Clear completed batch action
- Derived reactive stats (total, active, done counts)
- Premium dark theme with glassmorphism, slide-in animations
- No VDOM, no re-renders — pure signal propagation
This commit is contained in:
enzotar 2026-02-25 00:06:20 -08:00
parent a634152318
commit 51cf09336b
2 changed files with 583 additions and 0 deletions

35
examples/todomvc.ds Normal file
View file

@ -0,0 +1,35 @@
-- DreamStack TodoMVC
-- Full todo app demonstrating lists, input bindings, filtering,
-- and dynamic DOM manipulation — all with compile-time reactivity.
let todos = []
let filter = "all"
let next_id = 0
let input_text = ""
-- Derived: filtered list
let visible_todos = todos
let active_count = 0
let completed_count = 0
view app =
column [
text "todos" { class: "title" }
row [
input "" { placeholder: "What needs to be done?", value: input_text, class: "new-todo", keydown: input_text += "" }
]
column [
text visible_todos { class: "todo-list" }
]
row [
text active_count { class: "count" }
row [
button "All" { click: filter = "all", class: "filter-btn" }
button "Active" { click: filter = "active", class: "filter-btn" }
button "Done" { click: filter = "completed", class: "filter-btn" }
]
]
]

548
examples/todomvc.html Normal file
View file

@ -0,0 +1,548 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack TodoMVC</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
background: #0a0a0f;
color: #e2e8f0;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 3rem 1rem;
}
#app {
width: 100%;
max-width: 560px;
}
.title {
text-align: center;
font-size: 3.5rem;
font-weight: 200;
letter-spacing: -0.04em;
color: rgba(139, 92, 246, 0.4);
margin-bottom: 2rem;
text-transform: lowercase;
}
.card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
overflow: hidden;
backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.new-todo {
width: 100%;
padding: 1.25rem 1.5rem;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: #e2e8f0;
font-size: 1.1rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.new-todo::placeholder { color: rgba(255, 255, 255, 0.2); }
.new-todo:focus { border-bottom-color: rgba(99, 102, 241, 0.5); }
.todo-list {
list-style: none;
max-height: 400px;
overflow-y: auto;
}
.todo-list::-webkit-scrollbar { width: 4px; }
.todo-list::-webkit-scrollbar-track { background: transparent; }
.todo-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.todo-item {
display: flex;
align-items: center;
padding: 0.875rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
gap: 0.75rem;
animation: slide-in 0.25s ease-out;
transition: opacity 0.2s, transform 0.2s;
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.todo-item:hover { background: rgba(255, 255, 255, 0.02); }
.todo-item.completed .todo-text {
text-decoration: line-through;
color: rgba(255, 255, 255, 0.25);
}
.todo-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
background: transparent;
color: transparent;
font-size: 0.7rem;
}
.todo-check:hover {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(139, 92, 246, 0.1);
}
.todo-check.checked {
border-color: #8b5cf6;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
}
.todo-text {
flex: 1;
font-size: 0.95rem;
color: #e2e8f0;
transition: color 0.2s;
}
.todo-destroy {
opacity: 0;
background: none;
border: none;
color: rgba(239, 68, 68, 0.6);
font-size: 1.2rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.15s;
}
.todo-item:hover .todo-destroy { opacity: 1; }
.todo-destroy:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.count {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.35);
}
.filters {
display: flex;
gap: 0.25rem;
}
.filter-btn {
background: none;
border: 1px solid transparent;
color: rgba(255, 255, 255, 0.35);
font-size: 0.8rem;
font-family: inherit;
padding: 0.3rem 0.6rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover { color: rgba(255, 255, 255, 0.6); }
.filter-btn.selected {
border-color: rgba(139, 92, 246, 0.3);
color: #a78bfa;
background: rgba(139, 92, 246, 0.08);
}
.clear-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.35);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
transition: color 0.15s;
}
.clear-btn:hover { color: #ef4444; }
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: rgba(255, 255, 255, 0.15);
font-size: 0.9rem;
}
.stats {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 1.5rem;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.25);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
.powered-by {
text-align: center;
margin-top: 2rem;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.12);
}
.powered-by span {
color: rgba(139, 92, 246, 0.3);
}
</style>
</head>
<body>
<div id="app"></div>
<script>
// ═══════════════════════════════════════════════════════════
// DreamStack Reactive Runtime v0.1.0
// Identical to the compiler-embedded version, standalone for demo
// ═══════════════════════════════════════════════════════════
const DS = (() => {
let currentEffect = null;
let batchDepth = 0;
let pendingEffects = new Set();
class Signal {
constructor(val) {
this._value = val;
this._subs = new Set();
}
get value() {
if (currentEffect) this._subs.add(currentEffect);
return this._value;
}
set value(v) {
if (this._value === v) return;
this._value = v;
this._notify();
}
_notify() {
if (batchDepth > 0) {
for (const s of this._subs) pendingEffects.add(s);
} else {
for (const s of [...this._subs]) s._run();
}
}
// List mutation helpers (triggers reactivity)
push(item) { this._value = [...this._value, item]; this._notify(); }
remove(pred) { this._value = this._value.filter(x => !pred(x)); this._notify(); }
update(pred, fn) {
this._value = this._value.map(x => pred(x) ? fn(x) : x);
this._notify();
}
}
class Derived {
constructor(fn) {
this._fn = fn;
this._value = undefined;
this._subs = new Set();
this._eff = new Effect(() => {
const old = this._value;
this._value = this._fn();
if (old !== this._value) {
if (batchDepth > 0) {
for (const s of this._subs) pendingEffects.add(s);
} else {
for (const s of [...this._subs]) s._run();
}
}
});
this._eff._run();
}
get value() {
if (currentEffect) this._subs.add(currentEffect);
return this._value;
}
}
class Effect {
constructor(fn) { this._fn = fn; this._disposed = false; }
_run() {
if (this._disposed) return;
const prev = currentEffect;
currentEffect = this;
try { this._fn(); } finally { currentEffect = prev; }
}
dispose() { this._disposed = true; }
}
const signal = v => new Signal(v);
const derived = fn => new Derived(fn);
const effect = fn => { const e = new Effect(fn); e._run(); return e; };
const batch = fn => {
batchDepth++;
try { fn(); } finally {
batchDepth--;
if (batchDepth === 0) flush();
}
};
const flush = () => {
const effs = [...pendingEffects];
pendingEffects.clear();
for (const e of effs) e._run();
};
return { signal, derived, effect, batch, flush };
})();
// ═══════════════════════════════════════════════════════════
// TodoMVC App — built with DreamStack signals
// ═══════════════════════════════════════════════════════════
(() => {
// ── Signals ──
const todos = DS.signal([]);
const filter = DS.signal('all');
const nextId = DS.signal(0);
// ── Derived ──
const visibleTodos = DS.derived(() => {
const f = filter.value;
const t = todos.value;
if (f === 'active') return t.filter(x => !x.completed);
if (f === 'completed') return t.filter(x => x.completed);
return t;
});
const activeCount = DS.derived(() =>
todos.value.filter(x => !x.completed).length
);
const completedCount = DS.derived(() =>
todos.value.filter(x => x.completed).length
);
const totalCount = DS.derived(() => todos.value.length);
// ── Actions ──
function addTodo(text) {
const trimmed = text.trim();
if (!trimmed) return;
DS.batch(() => {
const id = nextId.value;
nextId.value = id + 1;
todos.push({ id, text: trimmed, completed: false });
});
}
function toggleTodo(id) {
todos.update(t => t.id === id, t => ({ ...t, completed: !t.completed }));
}
function removeTodo(id) {
todos.remove(t => t.id === id);
}
function clearCompleted() {
todos.remove(t => t.completed);
}
// ── DOM Construction ──
const app = document.getElementById('app');
// Title
const title = document.createElement('h1');
title.className = 'title';
title.textContent = 'todos';
app.appendChild(title);
// Card container
const card = document.createElement('div');
card.className = 'card';
app.appendChild(card);
// Input
const input = document.createElement('input');
input.className = 'new-todo';
input.placeholder = 'What needs to be done?';
input.autofocus = true;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
addTodo(input.value);
input.value = '';
}
});
card.appendChild(input);
// Todo list
const list = document.createElement('ul');
list.className = 'todo-list';
card.appendChild(list);
// Empty state
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.textContent = 'Nothing to do yet — add a task above';
// Reactive list rendering
DS.effect(() => {
const items = visibleTodos.value;
list.innerHTML = '';
if (items.length === 0 && todos.value.length === 0) {
list.appendChild(emptyState);
return;
}
if (items.length === 0) {
const noMatch = document.createElement('div');
noMatch.className = 'empty-state';
noMatch.textContent = 'No matching todos';
list.appendChild(noMatch);
return;
}
for (const todo of items) {
const li = document.createElement('li');
li.className = 'todo-item' + (todo.completed ? ' completed' : '');
const check = document.createElement('button');
check.className = 'todo-check' + (todo.completed ? ' checked' : '');
check.textContent = todo.completed ? '✓' : '';
check.addEventListener('click', () => toggleTodo(todo.id));
const text = document.createElement('span');
text.className = 'todo-text';
text.textContent = todo.text;
const destroy = document.createElement('button');
destroy.className = 'todo-destroy';
destroy.textContent = '×';
destroy.addEventListener('click', () => removeTodo(todo.id));
li.appendChild(check);
li.appendChild(text);
li.appendChild(destroy);
list.appendChild(li);
}
});
// Footer
const footer = document.createElement('div');
footer.className = 'footer';
card.appendChild(footer);
const countEl = document.createElement('span');
countEl.className = 'count';
footer.appendChild(countEl);
DS.effect(() => {
const n = activeCount.value;
countEl.textContent = `${n} item${n !== 1 ? 's' : ''} left`;
});
// Filters
const filtersDiv = document.createElement('div');
filtersDiv.className = 'filters';
footer.appendChild(filtersDiv);
['all', 'active', 'completed'].forEach(f => {
const btn = document.createElement('button');
btn.className = 'filter-btn';
btn.textContent = f.charAt(0).toUpperCase() + f.slice(1);
btn.addEventListener('click', () => { filter.value = f; });
DS.effect(() => {
btn.classList.toggle('selected', filter.value === f);
});
filtersDiv.appendChild(btn);
});
// Clear completed
const clearBtn = document.createElement('button');
clearBtn.className = 'clear-btn';
footer.appendChild(clearBtn);
DS.effect(() => {
const n = completedCount.value;
clearBtn.textContent = n > 0 ? `Clear (${n})` : '';
clearBtn.style.visibility = n > 0 ? 'visible' : 'hidden';
});
clearBtn.addEventListener('click', clearCompleted);
// Stats
const stats = document.createElement('div');
stats.className = 'stats';
app.appendChild(stats);
function makeStat(label) {
const stat = document.createElement('div');
stat.className = 'stat';
const val = document.createElement('div');
val.className = 'stat-value';
const lbl = document.createElement('div');
lbl.className = 'stat-label';
lbl.textContent = label;
stat.appendChild(val);
stat.appendChild(lbl);
stats.appendChild(stat);
return val;
}
const totalEl = makeStat('Total');
const activeEl = makeStat('Active');
const doneEl = makeStat('Done');
DS.effect(() => { totalEl.textContent = totalCount.value; });
DS.effect(() => { activeEl.textContent = activeCount.value; });
DS.effect(() => { doneEl.textContent = completedCount.value; });
// Powered by
const powered = document.createElement('div');
powered.className = 'powered-by';
powered.innerHTML = 'Built with <span>DreamStack</span> — no VDOM, no re-renders, pure signals';
app.appendChild(powered);
})();
</script>
</body>
</html>