549 lines
15 KiB
HTML
549 lines
15 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 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>
|