mirror of
https://github.com/obra/superpowers.git
synced 2026-04-22 01:19:04 +08:00
refactor: server-side frame wrapping and helper.js consolidation
Move toggleSelect/send/selectedChoice from frame-template.html inline script to helper.js so they're auto-injected. Server now detects bare HTML fragments (no DOCTYPE/html tag) and wraps them in the frame template automatically. Full documents pass through as before. Fix dark mode in sendToClaude confirmation (was using hardcoded colors). Fix test env var bug (BRAINSTORM_SCREEN -> BRAINSTORM_DIR). Add tests for fragment wrapping, full doc passthrough, and helper.js.
This commit is contained in:
@@ -232,28 +232,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedChoice = null;
|
||||
|
||||
function toggleSelect(el) {
|
||||
const container = el.closest('.options') || el.closest('.cards');
|
||||
if (container) {
|
||||
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
|
||||
}
|
||||
el.classList.add('selected');
|
||||
selectedChoice = el.dataset.choice;
|
||||
}
|
||||
|
||||
function send() {
|
||||
const feedbackEl = document.getElementById('feedback');
|
||||
const feedback = feedbackEl.value.trim();
|
||||
const payload = {};
|
||||
if (selectedChoice) payload.choice = selectedChoice;
|
||||
if (feedback) payload.feedback = feedback;
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
brainstorm.sendToClaude(payload);
|
||||
feedbackEl.value = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send any queued events
|
||||
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
||||
eventQueue = [];
|
||||
};
|
||||
@@ -20,12 +19,11 @@
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// Reconnect after 1 second
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
function send(event) {
|
||||
function sendEvent(event) {
|
||||
event.timestamp = Date.now();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(event));
|
||||
@@ -42,9 +40,12 @@
|
||||
// Don't capture regular link navigation
|
||||
if (target.tagName === 'A' && !target.dataset.choice) return;
|
||||
|
||||
// Don't capture the Send feedback button (handled by send())
|
||||
if (target.id === 'send-feedback') return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
send({
|
||||
sendEvent({
|
||||
type: 'click',
|
||||
text: target.textContent.trim(),
|
||||
choice: target.dataset.choice || null,
|
||||
@@ -61,7 +62,7 @@
|
||||
const data = {};
|
||||
formData.forEach((value, key) => { data[key] = value; });
|
||||
|
||||
send({
|
||||
sendEvent({
|
||||
type: 'submit',
|
||||
formId: form.id || null,
|
||||
formName: form.name || null,
|
||||
@@ -77,37 +78,60 @@
|
||||
|
||||
clearTimeout(inputTimeout);
|
||||
inputTimeout = setTimeout(() => {
|
||||
send({
|
||||
sendEvent({
|
||||
type: 'input',
|
||||
name: target.name || null,
|
||||
id: target.id || null,
|
||||
value: target.value,
|
||||
inputType: target.type || target.tagName.toLowerCase()
|
||||
});
|
||||
}, 500); // 500ms debounce
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Send to Claude - triggers server to exit and return all events
|
||||
// Send to Claude - triggers feedback delivery
|
||||
function sendToClaude(feedback) {
|
||||
send({
|
||||
sendEvent({
|
||||
type: 'send-to-claude',
|
||||
feedback: feedback || null
|
||||
});
|
||||
// Show confirmation to user
|
||||
// Show themed confirmation page
|
||||
document.body.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: system-ui, sans-serif;">
|
||||
<div style="text-align: center; color: #666;">
|
||||
<h2 style="color: #333;">✓ Sent to Claude</h2>
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg-primary, #f5f5f7);">
|
||||
<div style="text-align: center; color: var(--text-secondary, #86868b);">
|
||||
<h2 style="color: var(--text-primary, #1d1d1f); margin-bottom: 0.5rem;">Sent to Claude</h2>
|
||||
<p>Return to the terminal to see Claude's response.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Expose for explicit use if needed
|
||||
// Frame UI: selection tracking and feedback send
|
||||
window.selectedChoice = null;
|
||||
|
||||
window.toggleSelect = function(el) {
|
||||
const container = el.closest('.options') || el.closest('.cards');
|
||||
if (container) {
|
||||
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
|
||||
}
|
||||
el.classList.add('selected');
|
||||
window.selectedChoice = el.dataset.choice;
|
||||
};
|
||||
|
||||
window.send = function() {
|
||||
const feedbackEl = document.getElementById('feedback');
|
||||
const feedback = feedbackEl ? feedbackEl.value.trim() : '';
|
||||
const payload = {};
|
||||
if (window.selectedChoice) payload.choice = window.selectedChoice;
|
||||
if (feedback) payload.feedback = feedback;
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
sendToClaude(payload);
|
||||
if (feedbackEl) feedbackEl.value = '';
|
||||
};
|
||||
|
||||
// Expose API for explicit use
|
||||
window.brainstorm = {
|
||||
send: send,
|
||||
choice: (value, metadata = {}) => send({ type: 'choice', value, ...metadata }),
|
||||
send: sendEvent,
|
||||
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata }),
|
||||
sendToClaude: sendToClaude
|
||||
};
|
||||
|
||||
|
||||
@@ -5,15 +5,32 @@ const chokidar = require('chokidar');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Use provided port or pick a random high port (49152-65535)
|
||||
const PORT = process.env.BRAINSTORM_PORT || (49152 + Math.floor(Math.random() * 16383));
|
||||
const SCREEN_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
||||
|
||||
// Ensure screen directory exists
|
||||
if (!fs.existsSync(SCREEN_DIR)) {
|
||||
fs.mkdirSync(SCREEN_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Load frame template and helper script once at startup
|
||||
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
||||
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||
const helperInjection = `<script>\n${helperScript}\n</script>`;
|
||||
|
||||
// Detect whether content is a full HTML document or a bare fragment
|
||||
function isFullDocument(html) {
|
||||
const trimmed = html.trimStart().toLowerCase();
|
||||
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
||||
}
|
||||
|
||||
// Wrap a content fragment in the frame template
|
||||
function wrapInFrame(content) {
|
||||
return frameTemplate.replace(
|
||||
/(<div id="claude-content">)[\s\S]*?(<\/div>\s*<\/div>\s*<div class="feedback-footer">)/,
|
||||
`$1\n ${content}\n $2`
|
||||
);
|
||||
}
|
||||
|
||||
// Find the newest .html file in the directory by mtime
|
||||
function getNewestScreen() {
|
||||
const files = fs.readdirSync(SCREEN_DIR)
|
||||
@@ -28,7 +45,6 @@ function getNewestScreen() {
|
||||
return files.length > 0 ? files[0].path : null;
|
||||
}
|
||||
|
||||
// Default waiting page (served when no screens exist yet)
|
||||
const WAITING_PAGE = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -49,7 +65,6 @@ const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
// Track connected browsers for reload notifications
|
||||
const clients = new Set();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
@@ -57,7 +72,6 @@ wss.on('connection', (ws) => {
|
||||
ws.on('close', () => clients.delete(ws));
|
||||
|
||||
ws.on('message', (data) => {
|
||||
// User interaction event - write to stdout for Claude
|
||||
const event = JSON.parse(data.toString());
|
||||
console.log(JSON.stringify({ source: 'user-event', ...event }));
|
||||
});
|
||||
@@ -66,27 +80,30 @@ wss.on('connection', (ws) => {
|
||||
// Serve newest screen with helper.js injected
|
||||
app.get('/', (req, res) => {
|
||||
const screenFile = getNewestScreen();
|
||||
let html = screenFile ? fs.readFileSync(screenFile, 'utf-8') : WAITING_PAGE;
|
||||
let html;
|
||||
|
||||
// Inject helper script before </body>
|
||||
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||
const injection = `<script>\n${helperScript}\n</script>`;
|
||||
|
||||
if (html.includes('</body>')) {
|
||||
html = html.replace('</body>', `${injection}\n</body>`);
|
||||
if (!screenFile) {
|
||||
html = WAITING_PAGE;
|
||||
} else {
|
||||
html += injection;
|
||||
const raw = fs.readFileSync(screenFile, 'utf-8');
|
||||
html = isFullDocument(raw) ? raw : wrapInFrame(raw);
|
||||
}
|
||||
|
||||
// Inject helper script
|
||||
if (html.includes('</body>')) {
|
||||
html = html.replace('</body>', `${helperInjection}\n</body>`);
|
||||
} else {
|
||||
html += helperInjection;
|
||||
}
|
||||
|
||||
res.type('html').send(html);
|
||||
});
|
||||
|
||||
// Watch for new or changed .html files in the directory
|
||||
// Watch for new or changed .html files
|
||||
chokidar.watch(SCREEN_DIR, { ignoreInitial: true })
|
||||
.on('add', (filePath) => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
|
||||
// Notify all browsers to reload
|
||||
clients.forEach(ws => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'reload' }));
|
||||
|
||||
Reference in New Issue
Block a user