dreamstack/examples/search.html

857 lines
32 KiB
HTML
Raw Normal View History

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