dreamstack/examples/todomvc.html
enzotar 51cf09336b 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
2026-02-25 00:06:20 -08:00

548 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>