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:
parent
a634152318
commit
51cf09336b
2 changed files with 583 additions and 0 deletions
35
examples/todomvc.ds
Normal file
35
examples/todomvc.ds
Normal 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
548
examples/todomvc.html
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue