diff --git a/example_projects/twitter_clone_local/README.md b/example_projects/twitter_clone_local/README.md
new file mode 100644
index 0000000..2d53885
--- /dev/null
+++ b/example_projects/twitter_clone_local/README.md
@@ -0,0 +1,29 @@
+# Twitter Clone Local
+
+Minimal local Twitter-like demo (no npm dependencies).
+
+## Run
+
+```bash
+cd /home/bill/Documents/CLionProjects/whetstone_DSL/example_projects/twitter_clone_local
+node server.js
+```
+
+Then open:
+
+- http://localhost:3000
+
+## Features
+
+- Create tweets
+- Like tweets
+- Reply to tweets
+- Timeline refresh
+
+## API
+
+- `GET /api/health`
+- `GET /api/tweets`
+- `POST /api/tweets` body: `{ "author": "...", "text": "..." }`
+- `POST /api/tweets/:id/like`
+- `POST /api/tweets/:id/reply` body: `{ "author": "...", "text": "..." }`
diff --git a/example_projects/twitter_clone_local/public/app.js b/example_projects/twitter_clone_local/public/app.js
new file mode 100644
index 0000000..de87546
--- /dev/null
+++ b/example_projects/twitter_clone_local/public/app.js
@@ -0,0 +1,109 @@
+'use strict';
+
+const tweetsEl = document.getElementById('tweets');
+const refreshBtn = document.getElementById('refresh');
+const tweetForm = document.getElementById('tweet-form');
+const statusEl = document.getElementById('status');
+const template = document.getElementById('tweet-template');
+
+function fmtTime(iso) {
+ const d = new Date(iso);
+ return d.toLocaleString();
+}
+
+async function api(path, options = {}) {
+ const res = await fetch(path, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || `http_${res.status}`);
+ return data;
+}
+
+function renderTweets(tweets) {
+ tweetsEl.innerHTML = '';
+ for (const tweet of tweets) {
+ const node = template.content.firstElementChild.cloneNode(true);
+ node.dataset.tweetId = String(tweet.id);
+ node.querySelector('.author').textContent = `@${tweet.author}`;
+ node.querySelector('.time').textContent = fmtTime(tweet.createdAt);
+ node.querySelector('.tweet-text').textContent = tweet.text;
+ node.querySelector('.likes').textContent = `${tweet.likes} likes`;
+
+ const likeBtn = node.querySelector('.like-btn');
+ likeBtn.addEventListener('click', async () => {
+ try {
+ await api(`/api/tweets/${tweet.id}/like`, { method: 'POST' });
+ await loadTweets();
+ } catch (err) {
+ statusEl.textContent = `Like failed: ${err.message}`;
+ }
+ });
+
+ const repliesEl = node.querySelector('.replies');
+ for (const r of tweet.replies || []) {
+ const li = document.createElement('li');
+ li.textContent = `@${r.author}: ${r.text}`;
+ repliesEl.appendChild(li);
+ }
+
+ const replyForm = node.querySelector('.reply-form');
+ replyForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const author = replyForm.querySelector('.reply-author').value.trim();
+ const text = replyForm.querySelector('.reply-text').value.trim();
+ if (!author || !text) return;
+ try {
+ await api(`/api/tweets/${tweet.id}/reply`, {
+ method: 'POST',
+ body: JSON.stringify({ author, text })
+ });
+ await loadTweets();
+ } catch (err) {
+ statusEl.textContent = `Reply failed: ${err.message}`;
+ }
+ });
+
+ tweetsEl.appendChild(node);
+ }
+}
+
+async function loadTweets() {
+ const data = await api('/api/tweets');
+ renderTweets(data.tweets || []);
+}
+
+refreshBtn.addEventListener('click', async () => {
+ statusEl.textContent = '';
+ try {
+ await loadTweets();
+ } catch (err) {
+ statusEl.textContent = `Refresh failed: ${err.message}`;
+ }
+});
+
+tweetForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const form = new FormData(tweetForm);
+ const author = String(form.get('author') || '').trim();
+ const text = String(form.get('text') || '').trim();
+ if (!author || !text) return;
+
+ statusEl.textContent = 'Posting...';
+ try {
+ await api('/api/tweets', {
+ method: 'POST',
+ body: JSON.stringify({ author, text })
+ });
+ tweetForm.reset();
+ statusEl.textContent = 'Tweet posted.';
+ await loadTweets();
+ } catch (err) {
+ statusEl.textContent = `Post failed: ${err.message}`;
+ }
+});
+
+loadTweets().catch(err => {
+ statusEl.textContent = `Initial load failed: ${err.message}`;
+});
diff --git a/example_projects/twitter_clone_local/public/index.html b/example_projects/twitter_clone_local/public/index.html
new file mode 100644
index 0000000..bd0b160
--- /dev/null
+++ b/example_projects/twitter_clone_local/public/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ Twitter Clone Local
+
+
+
+
+
+
+
+
+
+
+
Timeline
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example_projects/twitter_clone_local/public/styles.css b/example_projects/twitter_clone_local/public/styles.css
new file mode 100644
index 0000000..d32d714
--- /dev/null
+++ b/example_projects/twitter_clone_local/public/styles.css
@@ -0,0 +1,75 @@
+:root {
+ --bg: #f5f7fa;
+ --card: #ffffff;
+ --line: #dde3ea;
+ --text: #15202b;
+ --muted: #617182;
+ --primary: #1d9bf0;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
+ background: linear-gradient(180deg, #f8fbff 0%, var(--bg) 100%);
+ color: var(--text);
+}
+.app {
+ max-width: 860px;
+ margin: 0 auto;
+ padding: 16px;
+ display: grid;
+ gap: 14px;
+}
+.topbar h1 { margin: 0; font-size: 28px; }
+.topbar p { margin: 4px 0 0; color: var(--muted); }
+.card {
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 14px;
+}
+form { display: grid; gap: 10px; }
+label { display: grid; gap: 6px; font-size: 14px; }
+input, textarea, button {
+ font: inherit;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 9px 10px;
+}
+textarea { min-height: 90px; resize: vertical; }
+button {
+ cursor: pointer;
+ background: var(--primary);
+ color: #fff;
+ border: none;
+}
+button:hover { filter: brightness(0.96); }
+.status { min-height: 20px; margin: 0; color: var(--muted); }
+.timeline-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.tweets { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.tweet {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 10px;
+ background: #fcfdff;
+}
+.tweet-head { display: flex; justify-content: space-between; gap: 10px; }
+.author { font-weight: 700; }
+.time { color: var(--muted); font-size: 12px; }
+.tweet-text { margin: 8px 0; white-space: pre-wrap; }
+.tweet-actions { display: flex; gap: 10px; align-items: center; }
+.replies-wrap { margin-top: 8px; }
+.replies { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 6px; }
+.replies li { border-left: 2px solid var(--line); padding-left: 8px; font-size: 14px; }
+.reply-form {
+ display: grid;
+ grid-template-columns: 140px 1fr auto;
+ gap: 6px;
+}
+@media (max-width: 700px) {
+ .reply-form { grid-template-columns: 1fr; }
+}
diff --git a/example_projects/twitter_clone_local/server.js b/example_projects/twitter_clone_local/server.js
new file mode 100644
index 0000000..72fc5c9
--- /dev/null
+++ b/example_projects/twitter_clone_local/server.js
@@ -0,0 +1,226 @@
+#!/usr/bin/env node
+'use strict';
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { URL } = require('url');
+
+const PORT = Number(process.env.PORT || 3000);
+const PUBLIC_DIR = path.join(__dirname, 'public');
+
+let nextTweetId = 4;
+let nextReplyId = 1;
+
+const tweets = [
+ {
+ id: 1,
+ author: 'whetstone',
+ text: 'Twitter clone local demo is live.',
+ likes: 3,
+ createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
+ replies: []
+ },
+ {
+ id: 2,
+ author: 'editor-bot',
+ text: 'You can post, like, and reply in this demo timeline.',
+ likes: 1,
+ createdAt: new Date(Date.now() - 1000 * 60 * 20).toISOString(),
+ replies: []
+ },
+ {
+ id: 3,
+ author: 'bill',
+ text: 'Running locally with zero external dependencies.',
+ likes: 2,
+ createdAt: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
+ replies: []
+ }
+];
+
+function sendJson(res, statusCode, payload) {
+ const body = JSON.stringify(payload);
+ res.writeHead(statusCode, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Content-Length': Buffer.byteLength(body)
+ });
+ res.end(body);
+}
+
+function sendText(res, statusCode, text) {
+ res.writeHead(statusCode, {
+ 'Content-Type': 'text/plain; charset=utf-8',
+ 'Content-Length': Buffer.byteLength(text)
+ });
+ res.end(text);
+}
+
+function parseBody(req) {
+ return new Promise((resolve, reject) => {
+ let raw = '';
+ req.on('data', chunk => {
+ raw += chunk;
+ if (raw.length > 1024 * 1024) {
+ reject(new Error('payload_too_large'));
+ }
+ });
+ req.on('end', () => {
+ if (!raw) {
+ resolve({});
+ return;
+ }
+ try {
+ resolve(JSON.parse(raw));
+ } catch {
+ reject(new Error('invalid_json'));
+ }
+ });
+ req.on('error', reject);
+ });
+}
+
+function getTweetById(id) {
+ return tweets.find(t => t.id === id) || null;
+}
+
+function listTweets() {
+ return [...tweets].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+}
+
+async function handleApi(req, res, pathname) {
+ if (req.method === 'GET' && pathname === '/api/health') {
+ sendJson(res, 200, { ok: true, service: 'twitter-clone-local' });
+ return;
+ }
+
+ if (req.method === 'GET' && pathname === '/api/tweets') {
+ sendJson(res, 200, { tweets: listTweets() });
+ return;
+ }
+
+ if (req.method === 'POST' && pathname === '/api/tweets') {
+ try {
+ const body = await parseBody(req);
+ const author = String(body.author || '').trim();
+ const text = String(body.text || '').trim();
+ if (!author || !text) {
+ sendJson(res, 400, { error: 'author_and_text_required' });
+ return;
+ }
+ const tweet = {
+ id: nextTweetId++,
+ author,
+ text,
+ likes: 0,
+ createdAt: new Date().toISOString(),
+ replies: []
+ };
+ tweets.push(tweet);
+ sendJson(res, 201, { tweet });
+ } catch (err) {
+ sendJson(res, 400, { error: err.message || 'invalid_request' });
+ }
+ return;
+ }
+
+ const likeMatch = pathname.match(/^\/api\/tweets\/(\d+)\/like$/);
+ if (req.method === 'POST' && likeMatch) {
+ const id = Number(likeMatch[1]);
+ const tweet = getTweetById(id);
+ if (!tweet) {
+ sendJson(res, 404, { error: 'tweet_not_found' });
+ return;
+ }
+ tweet.likes += 1;
+ sendJson(res, 200, { tweet });
+ return;
+ }
+
+ const replyMatch = pathname.match(/^\/api\/tweets\/(\d+)\/reply$/);
+ if (req.method === 'POST' && replyMatch) {
+ const id = Number(replyMatch[1]);
+ const tweet = getTweetById(id);
+ if (!tweet) {
+ sendJson(res, 404, { error: 'tweet_not_found' });
+ return;
+ }
+ try {
+ const body = await parseBody(req);
+ const author = String(body.author || '').trim();
+ const text = String(body.text || '').trim();
+ if (!author || !text) {
+ sendJson(res, 400, { error: 'author_and_text_required' });
+ return;
+ }
+ const reply = {
+ id: nextReplyId++,
+ author,
+ text,
+ createdAt: new Date().toISOString()
+ };
+ tweet.replies.push(reply);
+ sendJson(res, 201, { tweet, reply });
+ } catch (err) {
+ sendJson(res, 400, { error: err.message || 'invalid_request' });
+ }
+ return;
+ }
+
+ sendJson(res, 404, { error: 'not_found' });
+}
+
+function contentTypeFor(filePath) {
+ const ext = path.extname(filePath).toLowerCase();
+ if (ext === '.html') return 'text/html; charset=utf-8';
+ if (ext === '.css') return 'text/css; charset=utf-8';
+ if (ext === '.js') return 'application/javascript; charset=utf-8';
+ if (ext === '.json') return 'application/json; charset=utf-8';
+ return 'text/plain; charset=utf-8';
+}
+
+function safeFilePath(pathname) {
+ const cleanPath = pathname === '/' ? '/index.html' : pathname;
+ const abs = path.join(PUBLIC_DIR, path.normalize(cleanPath));
+ if (!abs.startsWith(PUBLIC_DIR)) return null;
+ return abs;
+}
+
+function serveStatic(res, pathname) {
+ const abs = safeFilePath(pathname);
+ if (!abs) {
+ sendText(res, 403, 'forbidden');
+ return;
+ }
+ fs.readFile(abs, (err, data) => {
+ if (err) {
+ if (err.code === 'ENOENT') {
+ sendText(res, 404, 'not found');
+ } else {
+ sendText(res, 500, 'internal error');
+ }
+ return;
+ }
+ res.writeHead(200, {
+ 'Content-Type': contentTypeFor(abs),
+ 'Content-Length': data.length
+ });
+ res.end(data);
+ });
+}
+
+const server = http.createServer(async (req, res) => {
+ const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ const pathname = parsedUrl.pathname;
+
+ if (pathname.startsWith('/api/')) {
+ await handleApi(req, res, pathname);
+ return;
+ }
+
+ serveStatic(res, pathname);
+});
+
+server.listen(PORT, () => {
+ process.stdout.write(`twitter_clone_local listening on http://localhost:${PORT}\n`);
+});