feat: Phase 2+3 — effects, streams, springs, search + dashboard
Phase 2 — Effect System & Streams: - Algebraic effects (perform/handle/handleAsync) for composable, testable side-effects with swappable handlers - Stream engine with map, filter, debounce, throttle, distinct, scan, flatMap, merge, fromEvent, fromSignal, fromPromise - Search-with-autocomplete example: debounce + flatMap + effects Phase 3 — Spring Physics: - RK4 integrator with fixed substep and sleep-when-idle scheduler - Springs are signals — anything that reads spring.value auto-updates - Dashboard example: spring-animated sidebar, staggered card entrance, draggable spring ball with configurable stiffness/damping/mass Runtime evolution: v0.1 (signals) → v0.2 (+ effects + streams) → v0.3 (+ springs)
This commit is contained in:
parent
51cf09336b
commit
fcf2639d9b
2 changed files with 1603 additions and 0 deletions
746
examples/dashboard.html
Normal file
746
examples/dashboard.html
Normal file
|
|
@ -0,0 +1,746 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DreamStack Dashboard — Springs + Layout</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, system-ui, sans-serif;
|
||||||
|
background: #0a0a0f;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: none;
|
||||||
|
/* springs handle animation */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spring-info {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: none;
|
||||||
|
/* springs handle this */
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05), rgba(139, 92, 246, 0.05));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6, #c084fc);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change.up {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change.down {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draggable-area {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spring-playground {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spring-ball {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
position: absolute;
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.3);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spring-ball:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spring-trail {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spring-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 120px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #8b5cf6;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b5cf6;
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 Runtime v0.3.0 — Signals + Effects + Springs
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
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;
|
||||||
|
if (batchDepth > 0) for (const s of this._subs) pendingEffects.add(s);
|
||||||
|
else for (const s of [...this._subs]) s._run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Spring Physics Engine
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// RK4 integrator, interruptible, integrates with signals
|
||||||
|
|
||||||
|
const activeSpringSet = new Set();
|
||||||
|
let rafId = null;
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
class Spring {
|
||||||
|
constructor({ value = 0, target = 0, stiffness = 170, damping = 26, mass = 1 } = {}) {
|
||||||
|
this._signal = signal(value);
|
||||||
|
this._velocity = 0;
|
||||||
|
this._target = target;
|
||||||
|
this.stiffness = stiffness;
|
||||||
|
this.damping = damping;
|
||||||
|
this.mass = mass;
|
||||||
|
this._settled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value() { return this._signal.value; }
|
||||||
|
set value(v) { this._signal.value = v; }
|
||||||
|
|
||||||
|
get target() { return this._target; }
|
||||||
|
set target(t) {
|
||||||
|
this._target = t;
|
||||||
|
this._settled = false;
|
||||||
|
activeSpringSet.add(this);
|
||||||
|
startLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantly set position (no animation)
|
||||||
|
set(v) {
|
||||||
|
this._signal.value = v;
|
||||||
|
this._target = v;
|
||||||
|
this._velocity = 0;
|
||||||
|
this._settled = true;
|
||||||
|
activeSpringSet.delete(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RK4 step
|
||||||
|
_step(dt) {
|
||||||
|
const pos = this._signal._value;
|
||||||
|
const vel = this._velocity;
|
||||||
|
const k = this.stiffness;
|
||||||
|
const d = this.damping;
|
||||||
|
const m = this.mass;
|
||||||
|
|
||||||
|
// RK4 derivatives
|
||||||
|
const accel = (p, v) => (-k * (p - this._target) - d * v) / m;
|
||||||
|
|
||||||
|
const k1v = accel(pos, vel);
|
||||||
|
const k1p = vel;
|
||||||
|
const k2v = accel(pos + k1p * dt / 2, vel + k1v * dt / 2);
|
||||||
|
const k2p = vel + k1v * dt / 2;
|
||||||
|
const k3v = accel(pos + k2p * dt / 2, vel + k2v * dt / 2);
|
||||||
|
const k3p = vel + k2v * dt / 2;
|
||||||
|
const k4v = accel(pos + k3p * dt, vel + k3v * dt);
|
||||||
|
const k4p = vel + k3v * dt;
|
||||||
|
|
||||||
|
const newVel = vel + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
|
||||||
|
const newPos = pos + (dt / 6) * (k1p + 2 * k2p + 2 * k3p + k4p);
|
||||||
|
|
||||||
|
this._velocity = newVel;
|
||||||
|
this._signal.value = newPos;
|
||||||
|
|
||||||
|
// Check if settled
|
||||||
|
if (Math.abs(newVel) < 0.01 && Math.abs(newPos - this._target) < 0.01) {
|
||||||
|
this._signal.value = this._target;
|
||||||
|
this._velocity = 0;
|
||||||
|
this._settled = true;
|
||||||
|
activeSpringSet.delete(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoop() {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
lastTime = performance.now();
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop(now) {
|
||||||
|
const dt = Math.min((now - lastTime) / 1000, 0.064); // clamp to ~15fps min
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const spring of activeSpringSet) {
|
||||||
|
// Fixed timestep substeps for stability
|
||||||
|
const steps = Math.ceil(dt / (1 / 120));
|
||||||
|
const subDt = dt / steps;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
spring._step(subDt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeSpringSet.size > 0) {
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
} else {
|
||||||
|
rafId = null; // Sleep when no springs are active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spring(opts) { return new Spring(opts); }
|
||||||
|
|
||||||
|
return { signal, derived, effect, batch, flush, spring, Spring };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Dashboard App
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
(() => {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
|
// ── Signals ──
|
||||||
|
const sidebarExpanded = DS.signal(true);
|
||||||
|
const activePage = DS.signal('dashboard');
|
||||||
|
|
||||||
|
// ── Springs ──
|
||||||
|
const sidebarWidth = DS.spring({ value: 240, target: 240, stiffness: 200, damping: 24 });
|
||||||
|
const sidebarOpacity = DS.spring({ value: 1, target: 1, stiffness: 200, damping: 24 });
|
||||||
|
|
||||||
|
// Card animations (staggered)
|
||||||
|
const cardScales = [
|
||||||
|
DS.spring({ value: 0, target: 1, stiffness: 300, damping: 22 }),
|
||||||
|
DS.spring({ value: 0, target: 1, stiffness: 300, damping: 22 }),
|
||||||
|
DS.spring({ value: 0, target: 1, stiffness: 300, damping: 22 }),
|
||||||
|
DS.spring({ value: 0, target: 1, stiffness: 300, damping: 22 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Draggable ball
|
||||||
|
const ballX = DS.spring({ value: 150, target: 150, stiffness: 170, damping: 26 });
|
||||||
|
const ballY = DS.spring({ value: 120, target: 120, stiffness: 170, damping: 26 });
|
||||||
|
|
||||||
|
// Stagger card entrance
|
||||||
|
cardScales.forEach((s, i) => {
|
||||||
|
setTimeout(() => { s.target = 1; }, i * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dashboard layout ──
|
||||||
|
const dashboard = document.createElement('div');
|
||||||
|
dashboard.className = 'dashboard';
|
||||||
|
app.appendChild(dashboard);
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
const sidebar = document.createElement('div');
|
||||||
|
sidebar.className = 'sidebar';
|
||||||
|
dashboard.appendChild(sidebar);
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
sidebar.style.width = sidebarWidth.value + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sidebar header
|
||||||
|
const sidebarHeader = document.createElement('div');
|
||||||
|
sidebarHeader.className = 'sidebar-header';
|
||||||
|
sidebarHeader.innerHTML = '<div class="logo">DS</div><div class="logo-text">DreamStack</div>';
|
||||||
|
sidebar.appendChild(sidebarHeader);
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
const logoText = sidebarHeader.querySelector('.logo-text');
|
||||||
|
logoText.style.opacity = sidebarOpacity.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nav items
|
||||||
|
const nav = document.createElement('div');
|
||||||
|
nav.className = 'sidebar-nav';
|
||||||
|
sidebar.appendChild(nav);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ icon: '📊', label: 'Dashboard', id: 'dashboard' },
|
||||||
|
{ icon: '📈', label: 'Analytics', id: 'analytics' },
|
||||||
|
{ icon: '⚡', label: 'Signals', id: 'signals' },
|
||||||
|
{ icon: '🎯', label: 'Effects', id: 'effects' },
|
||||||
|
{ icon: '🌊', label: 'Streams', id: 'streams' },
|
||||||
|
{ icon: '🎨', label: 'Springs', id: 'springs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
navItems.forEach(item => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'nav-item';
|
||||||
|
el.innerHTML = `<span class="nav-icon">${item.icon}</span><span class="nav-label">${item.label}</span>`;
|
||||||
|
el.addEventListener('click', () => { activePage.value = item.id; });
|
||||||
|
DS.effect(() => {
|
||||||
|
el.classList.toggle('active', activePage.value === item.id);
|
||||||
|
});
|
||||||
|
DS.effect(() => {
|
||||||
|
el.querySelector('.nav-label').style.opacity = sidebarOpacity.value;
|
||||||
|
});
|
||||||
|
nav.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle button
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'toggle-btn';
|
||||||
|
toggleBtn.textContent = '☰';
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
const expanded = !sidebarExpanded.value;
|
||||||
|
sidebarExpanded.value = expanded;
|
||||||
|
sidebarWidth.target = expanded ? 240 : 64;
|
||||||
|
sidebarOpacity.target = expanded ? 1 : 0;
|
||||||
|
});
|
||||||
|
sidebar.appendChild(toggleBtn);
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'main-content';
|
||||||
|
dashboard.appendChild(main);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const mainHeader = document.createElement('div');
|
||||||
|
mainHeader.className = 'main-header';
|
||||||
|
const pageTitle = document.createElement('h2');
|
||||||
|
pageTitle.className = 'page-title';
|
||||||
|
const springInfo = document.createElement('div');
|
||||||
|
springInfo.className = 'spring-info';
|
||||||
|
mainHeader.appendChild(pageTitle);
|
||||||
|
mainHeader.appendChild(springInfo);
|
||||||
|
main.appendChild(mainHeader);
|
||||||
|
|
||||||
|
DS.effect(() => { pageTitle.textContent = activePage.value.charAt(0).toUpperCase() + activePage.value.slice(1); });
|
||||||
|
DS.effect(() => {
|
||||||
|
springInfo.textContent = `sidebar: ${sidebarWidth.value.toFixed(0)}px | ball: (${ballX.value.toFixed(0)}, ${ballY.value.toFixed(0)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stats cards
|
||||||
|
const cardsGrid = document.createElement('div');
|
||||||
|
cardsGrid.className = 'cards-grid';
|
||||||
|
main.appendChild(cardsGrid);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ icon: '⚡', title: 'Signals', value: '1,247', change: '+12%', up: true },
|
||||||
|
{ icon: '🔄', title: 'Updates/s', value: '60', change: '0ms glitch', up: true },
|
||||||
|
{ icon: '📦', title: 'Bundle', value: '6.8KB', change: 'No VDOM', up: true },
|
||||||
|
{ icon: '🎯', title: 'Effects', value: '3', change: 'All handled', up: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
stats.forEach((stat, i) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
const s = cardScales[i].value;
|
||||||
|
card.style.transform = `scale(${s})`;
|
||||||
|
card.style.opacity = s;
|
||||||
|
});
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-icon">${stat.icon}</div>
|
||||||
|
<div class="card-title">${stat.title}</div>
|
||||||
|
<div class="card-value">${stat.value}</div>
|
||||||
|
<div class="card-change ${stat.up ? 'up' : 'down'}">${stat.change}</div>
|
||||||
|
`;
|
||||||
|
cardsGrid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draggable spring area
|
||||||
|
const dragArea = document.createElement('div');
|
||||||
|
dragArea.className = 'draggable-area';
|
||||||
|
const dragTitle = document.createElement('div');
|
||||||
|
dragTitle.className = 'drag-title';
|
||||||
|
dragTitle.textContent = '🎾 Spring Physics — Drag the ball';
|
||||||
|
dragArea.appendChild(dragTitle);
|
||||||
|
main.appendChild(dragArea);
|
||||||
|
|
||||||
|
const playground = document.createElement('div');
|
||||||
|
playground.className = 'spring-playground';
|
||||||
|
dragArea.appendChild(playground);
|
||||||
|
|
||||||
|
// Ball
|
||||||
|
const ball = document.createElement('div');
|
||||||
|
ball.className = 'spring-ball';
|
||||||
|
ball.textContent = '🎾';
|
||||||
|
playground.appendChild(ball);
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
ball.style.left = (ballX.value - 30) + 'px';
|
||||||
|
ball.style.top = (ballY.value - 30) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag handling
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
ball.addEventListener('mousedown', (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const rect = playground.getBoundingClientRect();
|
||||||
|
const x = Math.max(30, Math.min(rect.width - 30, e.clientX - rect.left));
|
||||||
|
const y = Math.max(30, Math.min(rect.height - 30, e.clientY - rect.top));
|
||||||
|
// Direct set while dragging (no spring)
|
||||||
|
ballX.set(x);
|
||||||
|
ballY.set(y);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
// Spring back to center
|
||||||
|
const rect = playground.getBoundingClientRect();
|
||||||
|
ballX.target = rect.width / 2;
|
||||||
|
ballY.target = rect.height / 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click anywhere in playground to send ball there
|
||||||
|
playground.addEventListener('click', (e) => {
|
||||||
|
if (isDragging) return;
|
||||||
|
const rect = playground.getBoundingClientRect();
|
||||||
|
ballX.target = e.clientX - rect.left;
|
||||||
|
ballY.target = e.clientY - rect.top;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spring controls
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'spring-controls';
|
||||||
|
dragArea.appendChild(controls);
|
||||||
|
|
||||||
|
function makeControl(label, min, max, initial, onChange) {
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'control-group';
|
||||||
|
|
||||||
|
const lbl = document.createElement('span');
|
||||||
|
lbl.className = 'control-label';
|
||||||
|
lbl.textContent = label;
|
||||||
|
|
||||||
|
const slider = document.createElement('input');
|
||||||
|
slider.type = 'range';
|
||||||
|
slider.className = 'control-slider';
|
||||||
|
slider.min = min;
|
||||||
|
slider.max = max;
|
||||||
|
slider.value = initial;
|
||||||
|
|
||||||
|
const val = document.createElement('span');
|
||||||
|
val.className = 'control-value';
|
||||||
|
val.textContent = initial;
|
||||||
|
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
val.textContent = slider.value;
|
||||||
|
onChange(Number(slider.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.appendChild(lbl);
|
||||||
|
group.appendChild(slider);
|
||||||
|
group.appendChild(val);
|
||||||
|
controls.appendChild(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeControl('Stiffness', 10, 500, 170, (v) => { ballX.stiffness = v; ballY.stiffness = v; });
|
||||||
|
makeControl('Damping', 1, 60, 26, (v) => { ballX.damping = v; ballY.damping = v; });
|
||||||
|
makeControl('Mass', 0.5, 10, 1, (v) => { ballX.mass = v; ballY.mass = v; });
|
||||||
|
|
||||||
|
// Powered by
|
||||||
|
const powered = document.createElement('div');
|
||||||
|
powered.className = 'powered-by';
|
||||||
|
powered.innerHTML = 'Built with <span>DreamStack</span> — springs + signals + zero re-renders';
|
||||||
|
main.appendChild(powered);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
857
examples/search.html
Normal file
857
examples/search.html
Normal file
|
|
@ -0,0 +1,857 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DreamStack Search — Effects + Streams</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, system-ui, sans-serif;
|
||||||
|
background: #0a0a0f;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 200;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(139, 92, 246, 0.4);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.25rem 1rem 3rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: rgba(99, 102, 241, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.loading {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.success {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title mark {
|
||||||
|
background: rgba(139, 92, 246, 0.25);
|
||||||
|
color: #c4b5fd;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(139, 92, 246, 0.4);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-log {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-log::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-log::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-log::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
animation: fade-in 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry .effect-name {
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry .stream-op {
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry .handler {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry .time {
|
||||||
|
color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 Runtime v0.2.0 — Signals + Effects + Streams
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const DS = (() => {
|
||||||
|
// ── Signal System (from v0.1.0) ──
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// NEW: Algebraic Effects
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Effects are composable, testable side-effect declarations.
|
||||||
|
// `perform(effectName, ...args)` throws to the nearest handler.
|
||||||
|
// `handle(fn, handlers)` installs effect handlers and runs fn.
|
||||||
|
|
||||||
|
const effectStack = [];
|
||||||
|
|
||||||
|
function perform(name, ...args) {
|
||||||
|
// Walk the handler stack to find a matching handler
|
||||||
|
for (let i = effectStack.length - 1; i >= 0; i--) {
|
||||||
|
const frame = effectStack[i];
|
||||||
|
if (frame.handlers[name]) {
|
||||||
|
return frame.handlers[name](...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled effect: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(fn, handlers) {
|
||||||
|
const frame = { handlers };
|
||||||
|
effectStack.push(frame);
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
effectStack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async version for effects that return promises
|
||||||
|
async function handleAsync(fn, handlers) {
|
||||||
|
const frame = { handlers };
|
||||||
|
effectStack.push(frame);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
effectStack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performAsync(name, ...args) {
|
||||||
|
for (let i = effectStack.length - 1; i >= 0; i--) {
|
||||||
|
const frame = effectStack[i];
|
||||||
|
if (frame.handlers[name]) {
|
||||||
|
return await frame.handlers[name](...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled effect: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// NEW: Stream Engine
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Streams are push-based async sequences that integrate
|
||||||
|
// with signals. A stream's latest value is a signal.
|
||||||
|
|
||||||
|
class Stream {
|
||||||
|
constructor(subscribe) {
|
||||||
|
this._subscribe = subscribe;
|
||||||
|
this._listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to values
|
||||||
|
listen(fn) {
|
||||||
|
const unsub = this._subscribe(fn);
|
||||||
|
this._listeners.push(fn);
|
||||||
|
return unsub || (() => {
|
||||||
|
this._listeners = this._listeners.filter(l => l !== fn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to signal (latest value)
|
||||||
|
toSignal(initial) {
|
||||||
|
const sig = signal(initial);
|
||||||
|
this.listen(v => { sig.value = v; });
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Operators ──
|
||||||
|
|
||||||
|
map(fn) {
|
||||||
|
return new Stream(emit => this.listen(v => emit(fn(v))));
|
||||||
|
}
|
||||||
|
|
||||||
|
filter(pred) {
|
||||||
|
return new Stream(emit => this.listen(v => { if (pred(v)) emit(v); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
debounce(ms) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
let timer = null;
|
||||||
|
return this.listen(v => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => emit(v), ms);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throttle(ms) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
let last = 0;
|
||||||
|
return this.listen(v => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - last >= ms) {
|
||||||
|
last = now;
|
||||||
|
emit(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
distinct() {
|
||||||
|
return new Stream(emit => {
|
||||||
|
let prev = undefined;
|
||||||
|
return this.listen(v => {
|
||||||
|
if (v !== prev) { prev = v; emit(v); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
take(n) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
let count = 0;
|
||||||
|
return this.listen(v => {
|
||||||
|
if (count < n) { count++; emit(v); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scan(fn, seed) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
let acc = seed;
|
||||||
|
return this.listen(v => {
|
||||||
|
acc = fn(acc, v);
|
||||||
|
emit(acc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
flatMap(fn) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
let innerUnsub = null;
|
||||||
|
return this.listen(v => {
|
||||||
|
if (innerUnsub) innerUnsub();
|
||||||
|
const innerStream = fn(v);
|
||||||
|
innerUnsub = innerStream.listen(emit);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static merge(...streams) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
const unsubs = streams.map(s => s.listen(emit));
|
||||||
|
return () => unsubs.forEach(u => u());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromEvent(element, event) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
element.addEventListener(event, emit);
|
||||||
|
return () => element.removeEventListener(event, emit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromSignal(sig) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
const eff = effect(() => emit(sig.value));
|
||||||
|
return () => eff.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPromise(promise) {
|
||||||
|
return new Stream(emit => {
|
||||||
|
promise.then(v => emit(v)).catch(() => { });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
signal, derived, effect, batch, flush,
|
||||||
|
perform, handle, handleAsync, performAsync,
|
||||||
|
Stream
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Search App — Algebraic Effects + Stream Pipeline
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
(() => {
|
||||||
|
const logEntries = DS.signal([]);
|
||||||
|
function log(type, msg) {
|
||||||
|
const now = new Date();
|
||||||
|
const time = now.toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
+ '.' + String(now.getMilliseconds()).padStart(3, '0');
|
||||||
|
logEntries.value = [...logEntries.value.slice(-50), { type, msg, time }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mock data (simulating a backend) ──
|
||||||
|
const MOCK_DATA = [
|
||||||
|
{ title: 'Reactive Signals', desc: 'Fine-grained push-based reactivity without VDOM', tags: ['signals', 'reactive'] },
|
||||||
|
{ title: 'Algebraic Effects', desc: 'Composable, testable side-effects with perform/handle', tags: ['effects', 'side-effects'] },
|
||||||
|
{ title: 'Stream Engine', desc: 'Push-based async sequences with operators like debounce and flatMap', tags: ['streams', 'async'] },
|
||||||
|
{ title: 'Constraint Layout', desc: 'Cassowary-based constraint solver replacing CSS flexbox', tags: ['layout', 'constraints'] },
|
||||||
|
{ title: 'Spring Physics', desc: 'Interruptible physics-based animations at 60fps', tags: ['animation', 'springs'] },
|
||||||
|
{ title: 'Compile-Time Analysis', desc: 'Signal dependency graph extracted at compile time for optimal updates', tags: ['compiler', 'static-analysis'] },
|
||||||
|
{ title: 'Homoiconic AST', desc: 'Code and data share the same representation for live editing', tags: ['homoiconic', 'editor'] },
|
||||||
|
{ title: 'Zero Re-renders', desc: 'Direct DOM mutations driven by signal subscriptions — no diffing', tags: ['dom', 'performance'] },
|
||||||
|
{ title: 'Effect Handlers', desc: 'Install custom handlers for HTTP, storage, time, and more', tags: ['effects', 'handlers'] },
|
||||||
|
{ title: 'Stream Operators', desc: 'map, filter, debounce, throttle, distinct, scan, flatMap, merge', tags: ['streams', 'operators'] },
|
||||||
|
{ title: 'Dependent Types', desc: 'Refinement types catch errors at compile time', tags: ['types', 'safety'] },
|
||||||
|
{ title: 'Live Structural Editor', desc: 'Bidirectional editing — change code or drag UI', tags: ['editor', 'live'] },
|
||||||
|
{ title: 'WASM Runtime', desc: 'Core signal graph and layout engine compiled to WebAssembly', tags: ['wasm', 'performance'] },
|
||||||
|
{ title: 'Pattern Matching', desc: 'Exhaustive pattern matching with destructuring', tags: ['language', 'patterns'] },
|
||||||
|
{ title: 'Pipe Operator', desc: 'Chain transformations with |> for readable data flow', tags: ['language', 'operators'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Effect declarations ──
|
||||||
|
// The search function performs an "Http" effect.
|
||||||
|
// Different handlers can provide different implementations:
|
||||||
|
// - Production: fetch from real API
|
||||||
|
// - Test: return mock data instantly
|
||||||
|
// - Demo: simulate network delay
|
||||||
|
|
||||||
|
async function search(query) {
|
||||||
|
log('effect', `perform Http.search("${query}")`);
|
||||||
|
const results = await DS.performAsync('Http.search', query);
|
||||||
|
log('handler', `← received ${results.length} results`);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Effect Handlers ──
|
||||||
|
|
||||||
|
// Demo handler: simulates network with delay + fuzzy search
|
||||||
|
const demoHandler = {
|
||||||
|
'Http.search': async (query) => {
|
||||||
|
log('handler', `DemoHandler: simulating 300ms network delay...`);
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return MOCK_DATA.filter(item =>
|
||||||
|
item.title.toLowerCase().includes(q) ||
|
||||||
|
item.desc.toLowerCase().includes(q) ||
|
||||||
|
item.tags.some(t => t.includes(q))
|
||||||
|
).map(item => ({
|
||||||
|
...item,
|
||||||
|
highlight: highlightMatch(item.title, query),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test handler: instant results, no delay
|
||||||
|
const testHandler = {
|
||||||
|
'Http.search': (query) => {
|
||||||
|
log('handler', `TestHandler: instant mock response`);
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return MOCK_DATA.filter(item =>
|
||||||
|
item.title.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function highlightMatch(text, query) {
|
||||||
|
if (!query) return text;
|
||||||
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (idx === -1) return text;
|
||||||
|
return text.slice(0, idx) + '<mark>' + text.slice(idx, idx + query.length) + '</mark>' + text.slice(idx + query.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Signals ──
|
||||||
|
const query = DS.signal('');
|
||||||
|
const results = DS.signal([]);
|
||||||
|
const loading = DS.signal(false);
|
||||||
|
const error = DS.signal(null);
|
||||||
|
const resultCount = DS.derived(() => results.value.length);
|
||||||
|
const searchCount = DS.signal(0);
|
||||||
|
|
||||||
|
// ── Stream Pipeline ──
|
||||||
|
// input events → debounce 250ms → distinct → flatMap(search)
|
||||||
|
// This is the core stream pipeline: real streaming data flow
|
||||||
|
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'header';
|
||||||
|
header.innerHTML = '<h1 class="title">search</h1><p class="subtitle">Effects + Streams — debounced, cancellable, testable</p>';
|
||||||
|
app.appendChild(header);
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
const searchBox = document.createElement('div');
|
||||||
|
searchBox.className = 'search-box';
|
||||||
|
const searchIcon = document.createElement('span');
|
||||||
|
searchIcon.className = 'search-icon';
|
||||||
|
searchIcon.textContent = '⌕';
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'search-input';
|
||||||
|
input.placeholder = 'Search DreamStack features...';
|
||||||
|
input.autofocus = true;
|
||||||
|
searchBox.appendChild(searchIcon);
|
||||||
|
searchBox.appendChild(input);
|
||||||
|
app.appendChild(searchBox);
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
const statusBar = document.createElement('div');
|
||||||
|
statusBar.className = 'status-bar';
|
||||||
|
const statusDot = document.createElement('div');
|
||||||
|
statusDot.className = 'status-dot';
|
||||||
|
const statusText = document.createElement('span');
|
||||||
|
statusBar.appendChild(statusDot);
|
||||||
|
statusBar.appendChild(statusText);
|
||||||
|
app.appendChild(statusBar);
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
const l = loading.value;
|
||||||
|
const r = results.value;
|
||||||
|
const q = query.value;
|
||||||
|
const e = error.value;
|
||||||
|
|
||||||
|
statusDot.className = 'status-dot' + (l ? ' loading' : e ? ' error' : r.length > 0 ? ' success' : '');
|
||||||
|
if (l) {
|
||||||
|
statusText.textContent = 'Searching...';
|
||||||
|
} else if (e) {
|
||||||
|
statusText.textContent = `Error: ${e}`;
|
||||||
|
} else if (q && r.length > 0) {
|
||||||
|
statusText.textContent = `${r.length} results for "${q}" (${searchCount.value} searches)`;
|
||||||
|
} else if (q) {
|
||||||
|
statusText.textContent = `No results for "${q}"`;
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'Type to search — results debounced at 250ms';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Results list
|
||||||
|
const resultsCard = document.createElement('div');
|
||||||
|
resultsCard.className = 'results-card';
|
||||||
|
app.appendChild(resultsCard);
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
const r = results.value;
|
||||||
|
const q = query.value;
|
||||||
|
const l = loading.value;
|
||||||
|
resultsCard.innerHTML = '';
|
||||||
|
|
||||||
|
if (!q && !l) {
|
||||||
|
resultsCard.innerHTML = '<div class="empty-state">Start typing to search DreamStack features</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (l) {
|
||||||
|
resultsCard.innerHTML = '<div class="empty-state">⏳ Loading...</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.length === 0) {
|
||||||
|
resultsCard.innerHTML = `<div class="empty-state">No results for "${q}"</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of r) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'result-item';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'result-title';
|
||||||
|
titleEl.innerHTML = item.highlight || item.title;
|
||||||
|
|
||||||
|
const descEl = document.createElement('div');
|
||||||
|
descEl.className = 'result-desc';
|
||||||
|
descEl.textContent = item.desc;
|
||||||
|
|
||||||
|
const metaEl = document.createElement('div');
|
||||||
|
metaEl.className = 'result-meta';
|
||||||
|
metaEl.textContent = item.tags.map(t => `#${t}`).join(' ');
|
||||||
|
|
||||||
|
el.appendChild(titleEl);
|
||||||
|
el.appendChild(descEl);
|
||||||
|
el.appendChild(metaEl);
|
||||||
|
resultsCard.appendChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Effect log
|
||||||
|
const logContainer = document.createElement('div');
|
||||||
|
logContainer.className = 'effect-log';
|
||||||
|
app.appendChild(logContainer);
|
||||||
|
|
||||||
|
DS.effect(() => {
|
||||||
|
const entries = logEntries.value;
|
||||||
|
logContainer.innerHTML = '';
|
||||||
|
for (const entry of entries) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'log-entry';
|
||||||
|
const typeClass = entry.type === 'effect' ? 'effect-name'
|
||||||
|
: entry.type === 'stream' ? 'stream-op'
|
||||||
|
: 'handler';
|
||||||
|
el.innerHTML = `<span class="time">${entry.time}</span> <span class="${typeClass}">[${entry.type}]</span> ${entry.msg}`;
|
||||||
|
logContainer.appendChild(el);
|
||||||
|
}
|
||||||
|
logContainer.scrollTop = logContainer.scrollHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
const legend = document.createElement('div');
|
||||||
|
legend.className = 'legend';
|
||||||
|
[
|
||||||
|
['#8b5cf6', 'perform (effect)'],
|
||||||
|
['#22d3ee', 'stream operator'],
|
||||||
|
['#22c55e', 'handler (response)'],
|
||||||
|
].forEach(([color, label]) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'legend-item';
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'legend-dot';
|
||||||
|
dot.style.background = color;
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = label;
|
||||||
|
item.appendChild(dot);
|
||||||
|
item.appendChild(text);
|
||||||
|
legend.appendChild(item);
|
||||||
|
});
|
||||||
|
app.appendChild(legend);
|
||||||
|
|
||||||
|
// Powered by
|
||||||
|
const powered = document.createElement('div');
|
||||||
|
powered.className = 'powered-by';
|
||||||
|
powered.innerHTML = 'Built with <span>DreamStack</span> — algebraic effects + streams';
|
||||||
|
app.appendChild(powered);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Wire up the stream pipeline
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
const inputStream = DS.Stream.fromEvent(input, 'input')
|
||||||
|
.map(e => {
|
||||||
|
log('stream', `input → "${e.target.value}"`);
|
||||||
|
return e.target.value;
|
||||||
|
})
|
||||||
|
.debounce(250)
|
||||||
|
.map(v => { log('stream', `debounce(250ms) → "${v}"`); return v; })
|
||||||
|
.distinct()
|
||||||
|
.map(v => { log('stream', `distinct → "${v}"`); return v; });
|
||||||
|
|
||||||
|
// flatMap: each new query cancels the previous search
|
||||||
|
let currentSearchId = 0;
|
||||||
|
|
||||||
|
inputStream.listen(async (q) => {
|
||||||
|
const id = ++currentSearchId;
|
||||||
|
query.value = q;
|
||||||
|
|
||||||
|
if (!q.trim()) {
|
||||||
|
results.value = [];
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
log('stream', `flatMap → starting search #${id}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use algebraic effects! The handler is swappable.
|
||||||
|
const r = await DS.handleAsync(
|
||||||
|
() => search(q),
|
||||||
|
demoHandler // Swap to testHandler for instant results
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only update if this is still the current search (cancel stale)
|
||||||
|
if (id === currentSearchId) {
|
||||||
|
DS.batch(() => {
|
||||||
|
results.value = r;
|
||||||
|
loading.value = false;
|
||||||
|
searchCount.value = searchCount.value + 1;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log('stream', `search #${id} cancelled (superseded by #${currentSearchId})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (id === currentSearchId) {
|
||||||
|
DS.batch(() => {
|
||||||
|
error.value = err.message;
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log initial state
|
||||||
|
log('effect', 'DreamStack v0.2.0 — Effects + Streams');
|
||||||
|
log('effect', 'Effect handlers installed: Http.search → DemoHandler');
|
||||||
|
log('stream', 'Pipeline: input → debounce(250ms) → distinct → flatMap(search)');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue