feat: add visual companion for brainstorming skill

Adds browser-based mockup display to replace ASCII art during
brainstorming sessions. Key components:

- Frame template with OS-aware light/dark theming
- CSS helpers for options, cards, mockups, split views
- Server lifecycle scripts (start/stop with random high port)
- Event watcher using tail+grep for feedback loop
- Claude instructions for using the visual companion

The skill now asks users if they want browser mockups and only
runs in Claude Code environments.
This commit is contained in:
Jesse Vincent
2026-01-17 16:47:03 -08:00
parent 94d5f4a817
commit 2a61167b02
7 changed files with 665 additions and 64 deletions

View File

@@ -0,0 +1,199 @@
# Visual Companion Instructions for Claude
This document explains how to use the brainstorm visual companion to show mockups, designs, and options to users without resorting to ASCII art.
## When to Use
Use the visual companion when you need to show:
- **UI mockups** - layouts, navigation patterns, component designs
- **Design comparisons** - "Which of these 3 approaches works better?"
- **Interactive prototypes** - clickable wireframes
- **Visual choices** - anything where seeing beats describing
**Don't use it for:** simple text questions, code review, or when the user prefers terminal-only interaction.
## Lifecycle
```bash
# Start server (returns JSON with URL)
${CLAUDE_PLUGIN_ROOT}/lib/brainstorm-server/start-server.sh
# Tell user to open the URL in their browser
# Write screens to /tmp/brainstorm/screen.html (auto-refreshes)
# Wait for user feedback
${CLAUDE_PLUGIN_ROOT}/lib/brainstorm-server/wait-for-event.sh /path/to/server.log
# When done, stop server
${CLAUDE_PLUGIN_ROOT}/lib/brainstorm-server/stop-server.sh
```
## Writing Screens
Copy the frame template structure but replace `#claude-content` with your content:
```html
<div id="claude-content">
<h2>Your Question</h2>
<p class="subtitle">Brief context</p>
<!-- Your content here -->
</div>
```
The frame template (`frame-template.html`) includes CSS for:
- OS-aware light/dark theming
- Fixed header and feedback footer
- Common UI patterns (see below)
## CSS Helper Classes
### Options (A/B/C choices)
```html
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Option Title</h3>
<p>Description of this option</p>
</div>
</div>
<!-- More options... -->
</div>
```
### Cards (visual designs)
```html
<div class="cards">
<div class="card" data-choice="design1" onclick="toggleSelect(this)">
<div class="card-image">
<!-- Put mockup content here -->
</div>
<div class="card-body">
<h3>Design Name</h3>
<p>Brief description</p>
</div>
</div>
</div>
```
### Mockup Container
```html
<div class="mockup">
<div class="mockup-header">Preview: Dashboard Layout</div>
<div class="mockup-body">
<!-- Your mockup HTML -->
</div>
</div>
```
### Split View (side-by-side)
```html
<div class="split">
<div class="mockup"><!-- Left side --></div>
<div class="mockup"><!-- Right side --></div>
</div>
```
### Pros/Cons
```html
<div class="pros-cons">
<div class="pros">
<h4>Pros</h4>
<ul>
<li>Benefit one</li>
<li>Benefit two</li>
</ul>
</div>
<div class="cons">
<h4>Cons</h4>
<ul>
<li>Drawback one</li>
<li>Drawback two</li>
</ul>
</div>
</div>
```
### Inline Mockup Elements
```html
<div class="mock-nav">Logo | Home | About | Contact</div>
<div style="display: flex;">
<div class="mock-sidebar">Navigation</div>
<div class="mock-content">Main content area</div>
</div>
<button class="mock-button">Action Button</button>
<input class="mock-input" placeholder="Input field">
```
## User Feedback
When the user clicks Send, you receive JSON like:
```json
{"choice": "a", "feedback": "I like this but make the header smaller"}
```
- `choice` - which option/card they selected (from `data-choice` attribute)
- `feedback` - any notes they typed
## Example: Design Comparison
```html
<div id="claude-content">
<h2>Which blog layout works better?</h2>
<p class="subtitle">Consider readability and visual hierarchy</p>
<div class="cards">
<div class="card" data-choice="classic" onclick="toggleSelect(this)">
<div class="card-image">
<div style="padding: 1rem;">
<div class="mock-nav">Blog Title</div>
<div style="padding: 1rem;">
<h3 style="margin-bottom: 0.5rem;">Post Title</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem;">
Content preview text goes here...
</p>
</div>
</div>
</div>
<div class="card-body">
<h3>Classic Layout</h3>
<p>Traditional blog with posts in a single column</p>
</div>
</div>
<div class="card" data-choice="magazine" onclick="toggleSelect(this)">
<div class="card-image">
<div style="padding: 1rem;">
<div class="mock-nav">Blog Title</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; padding: 0.5rem;">
<div class="placeholder" style="padding: 1rem;">Featured</div>
<div class="placeholder" style="padding: 0.5rem;">Post</div>
</div>
</div>
</div>
<div class="card-body">
<h3>Magazine Layout</h3>
<p>Grid-based with featured posts</p>
</div>
</div>
</div>
</div>
```
## Tips
1. **Keep mockups simple** - Focus on layout and structure, not pixel-perfect design
2. **Use placeholders** - The `.placeholder` class works great for content areas
3. **Label clearly** - Use `.mockup-header` to explain what each mockup shows
4. **Limit choices** - 2-4 options is ideal; more gets overwhelming
5. **Provide context** - Use `.subtitle` to explain what you're asking
6. **Regenerate fully** - Write the complete HTML each turn; don't try to patch

View File

@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html>
<head>
<title>Brainstorm Companion</title>
<style>
/*
* BRAINSTORM COMPANION FRAME TEMPLATE
*
* This template provides a consistent frame with:
* - OS-aware light/dark theming
* - Fixed header and feedback footer
* - Scrollable main content area
* - CSS helpers for common UI patterns
*
* CLAUDE: Replace the contents of #claude-content with your content.
* Keep the header, main wrapper, and feedback-footer intact.
*/
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
/* ===== THEME VARIABLES ===== */
:root {
--bg-primary: #f5f5f7;
--bg-secondary: #ffffff;
--bg-tertiary: #e5e5e7;
--border: #d1d1d6;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--text-tertiary: #aeaeb2;
--accent: #0071e3;
--accent-hover: #0077ed;
--success: #34c759;
--warning: #ff9f0a;
--error: #ff3b30;
--selected-bg: #e8f4fd;
--selected-border: #0071e3;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1d1d1f;
--bg-secondary: #2d2d2f;
--bg-tertiary: #3d3d3f;
--border: #424245;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--text-tertiary: #636366;
--accent: #0a84ff;
--accent-hover: #409cff;
--selected-bg: rgba(10, 132, 255, 0.15);
--selected-border: #0a84ff;
}
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
line-height: 1.5;
}
/* ===== FRAME STRUCTURE ===== */
.header {
background: var(--bg-secondary);
padding: 0.5rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
.header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; }
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; }
.main { flex: 1; overflow-y: auto; }
#claude-content { padding: 2rem; min-height: 100%; }
.feedback-footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 0.75rem 1.5rem;
flex-shrink: 0;
}
.feedback-footer label { display: block; font-size: 0.65rem; color: var(--text-secondary); margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
.feedback-row { display: flex; gap: 0.5rem; }
.feedback-footer textarea {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
resize: none;
height: 36px;
}
.feedback-footer textarea:focus { outline: none; border-color: var(--accent); }
.feedback-footer button {
background: var(--accent);
color: white;
border: none;
padding: 0 1rem;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.feedback-footer button:hover { background: var(--accent-hover); }
/* ===== TYPOGRAPHY ===== */
h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
.section { margin-bottom: 2rem; }
.label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
/* ===== OPTIONS (for A/B/C choices) ===== */
.options { display: flex; flex-direction: column; gap: 0.75rem; }
.option {
background: var(--bg-secondary);
border: 2px solid var(--border);
border-radius: 12px;
padding: 1rem 1.25rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: flex-start;
gap: 1rem;
}
.option:hover { border-color: var(--accent); }
.option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
.option .letter {
background: var(--bg-tertiary);
color: var(--text-secondary);
width: 1.75rem; height: 1.75rem;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
}
.option.selected .letter { background: var(--accent); color: white; }
.option .content { flex: 1; }
.option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
.option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
/* ===== CARDS (for showing designs/mockups) ===== */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.card.selected { border-color: var(--selected-border); border-width: 2px; }
.card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
.card-body { padding: 1rem; }
.card-body h3 { margin-bottom: 0.25rem; }
.card-body p { color: var(--text-secondary); font-size: 0.85rem; }
/* ===== MOCKUP CONTAINER ===== */
.mockup {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.mockup-header {
background: var(--bg-tertiary);
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.mockup-body { padding: 1.5rem; }
/* ===== SPLIT VIEW (side-by-side comparison) ===== */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
@media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
/* ===== PROS/CONS ===== */
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
.pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
.pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
.cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
.pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
.pros li, .cons li { margin-bottom: 0.25rem; }
/* ===== PLACEHOLDER (for mockup areas) ===== */
.placeholder {
background: var(--bg-tertiary);
border: 2px dashed var(--border);
border-radius: 8px;
padding: 2rem;
text-align: center;
color: var(--text-tertiary);
}
/* ===== INLINE MOCKUP ELEMENTS ===== */
.mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
.mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
.mock-content { padding: 1.5rem; flex: 1; }
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
</style>
</head>
<body>
<div class="header">
<h1>Brainstorm Companion</h1>
<div class="status">Connected</div>
</div>
<div class="main">
<div id="claude-content">
<!-- CLAUDE: Replace this content -->
<h2>Visual Brainstorming</h2>
<p class="subtitle">Claude will show mockups and options here.</p>
</div>
</div>
<div class="feedback-footer">
<label>Feedback for Claude</label>
<div class="feedback-row">
<textarea id="feedback" placeholder="Add notes (optional)..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}"></textarea>
<button onclick="send()">Send</button>
</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>

View File

@@ -5,7 +5,8 @@ const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');
const PORT = process.env.BRAINSTORM_PORT || 3333;
// 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_FILE = process.env.BRAINSTORM_SCREEN || '/tmp/brainstorm/screen.html';
const SCREEN_DIR = path.dirname(SCREEN_FILE);

View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Start the brainstorm server and output connection info
# Usage: start-server.sh
#
# Starts server on a random high port, outputs JSON with URL
# Server runs in background, PID saved for cleanup
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SCREEN_DIR="${BRAINSTORM_SCREEN_DIR:-/tmp/brainstorm}"
SCREEN_FILE="${SCREEN_DIR}/screen.html"
PID_FILE="${SCREEN_DIR}/.server.pid"
LOG_FILE="${SCREEN_DIR}/.server.log"
# Ensure screen directory exists
mkdir -p "$SCREEN_DIR"
# Kill any existing server
if [[ -f "$PID_FILE" ]]; then
old_pid=$(cat "$PID_FILE")
kill "$old_pid" 2>/dev/null
rm -f "$PID_FILE"
fi
# Start server, capturing output to log file
cd "$SCRIPT_DIR"
node index.js > "$LOG_FILE" 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > "$PID_FILE"
# Wait for server-started message (check log file)
for i in {1..50}; do
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
# Extract and output the server-started line
grep "server-started" "$LOG_FILE" | head -1
exit 0
fi
sleep 0.1
done
# Timeout - server didn't start
echo '{"error": "Server failed to start within 5 seconds"}'
exit 1

View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Stop the brainstorm server and clean up
# Usage: stop-server.sh
SCREEN_DIR="${BRAINSTORM_SCREEN_DIR:-/tmp/brainstorm}"
PID_FILE="${SCREEN_DIR}/.server.pid"
if [[ -f "$PID_FILE" ]]; then
pid=$(cat "$PID_FILE")
kill "$pid" 2>/dev/null
rm -f "$PID_FILE"
echo '{"status": "stopped"}'
else
echo '{"status": "not_running"}'
fi