mirror of
https://github.com/obra/superpowers.git
synced 2026-06-16 15:49:05 +08:00
Add visual companion Prime Radiant branding
This commit is contained in:
11
README.md
11
README.md
@@ -18,6 +18,17 @@ Next up, once you say "go", it launches a *subagent-driven-development* process,
|
||||
|
||||
There's a bunch more to it, but that's the core of the system. And because the skills trigger automatically, you don't need to do anything special. Your coding agent just has Superpowers.
|
||||
|
||||
### Visual companion
|
||||
|
||||
The brainstorming skill can open a browser-based visual companion for mockups,
|
||||
diagrams, and visual choices. Its frame shows `Superpowers v<version> by Prime
|
||||
Radiant` and, by default, loads the Prime Radiant logo from
|
||||
`https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png?v=<version>`.
|
||||
Set `SUPERPOWERS_DISABLE_TELEMETRY=1` before starting the companion server to
|
||||
omit the remote image; local text branding remains visible. The image URL
|
||||
carries only the Superpowers version, not event names, session IDs, prompts,
|
||||
project paths, or browser interaction data.
|
||||
|
||||
|
||||
## Sponsorship
|
||||
|
||||
|
||||
@@ -83,7 +83,10 @@ prime-radiant-inc.github.io/static/brand/superpowers-visual-brainstorming-logo.p
|
||||
|
||||
It can be the same pixels as an existing Prime Radiant logo, but the path should be unique to this integration so Cloudflare filtering is simple and does not mix normal website branding traffic with Superpowers visual companion traffic.
|
||||
|
||||
Production `primeradiant.com` should be proxied through Cloudflare so Cloudflare sees requests for this path even when GitHub Pages remains the origin.
|
||||
Production `primeradiant.com` should be proxied through Cloudflare before we
|
||||
expect telemetry from this path. The Superpowers and website changes can ship
|
||||
before that DNS work; in that state the logo still renders from GitHub Pages,
|
||||
but Cloudflare will not see or count the requests.
|
||||
|
||||
## Cloudflare Analytics
|
||||
|
||||
@@ -143,11 +146,11 @@ Website verification should cover:
|
||||
## Rollout
|
||||
|
||||
1. Add the dedicated static logo asset to the Prime Radiant website.
|
||||
2. Put `primeradiant.com` behind Cloudflare proxying if it is not already proxied.
|
||||
3. Update Superpowers visual companion branding to render the dynamic version and remote logo URL.
|
||||
4. Document `SUPERPOWERS_DISABLE_TELEMETRY=1`.
|
||||
5. Smoke test with telemetry enabled and disabled.
|
||||
6. Confirm the versioned logo request appears in Cloudflare analytics.
|
||||
2. Update Superpowers visual companion branding to render the dynamic version and remote logo URL.
|
||||
3. Document `SUPERPOWERS_DISABLE_TELEMETRY=1`.
|
||||
4. Smoke test with telemetry enabled and disabled.
|
||||
5. When DNS can change, put `primeradiant.com` behind Cloudflare proxying.
|
||||
6. Confirm the versioned logo request appears in Cloudflare analytics after Cloudflare is in the request path.
|
||||
|
||||
## Future Work
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
.header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); }
|
||||
.header .status { font-size: 0.7rem; color: var(--status-color, var(--success)); display: flex; align-items: center; gap: 0.4rem; }
|
||||
.header .status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; }
|
||||
.brand { display: flex; align-items: center; gap: 0.6rem; min-width: 0; color: var(--text-secondary); }
|
||||
.brand a { color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.brand-logo { height: 18px; width: auto; max-width: 180px; flex-shrink: 0; }
|
||||
|
||||
.main { flex: 1; overflow-y: auto; }
|
||||
#frame-content { padding: 2rem; min-height: 100%; }
|
||||
@@ -196,7 +199,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-decoration: none;">Superpowers Brainstorming</a></h1>
|
||||
<!-- BRANDING -->
|
||||
<div class="status">Connecting…</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'loc
|
||||
const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
||||
const CONTENT_DIR = path.join(SESSION_DIR, 'content');
|
||||
const STATE_DIR = path.join(SESSION_DIR, 'state');
|
||||
const SUPERPOWERS_VERSION = readSuperpowersVersion();
|
||||
const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
|
||||
const SUPERPOWERS_TELEMETRY_DISABLED = process.env.SUPERPOWERS_DISABLE_TELEMETRY === '1';
|
||||
let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
|
||||
|
||||
// Per-session secret key. The companion is reachable by any local browser tab
|
||||
@@ -150,14 +153,21 @@ const MIME_TYPES = {
|
||||
|
||||
// ========== Templates and Constants ==========
|
||||
|
||||
const WAITING_PAGE = `<!DOCTYPE html>
|
||||
function waitingPage() {
|
||||
return renderBranding(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Brainstorm Companion</title>
|
||||
<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #333; } p { color: #666; }</style>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #333; } p { color: #666; }
|
||||
.brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.5rem; color: #666; font-size: 0.9rem; }
|
||||
.brand a { color: inherit; text-decoration: none; }
|
||||
.brand-logo { height: 20px; width: auto; max-width: 180px; }
|
||||
</style>
|
||||
</head>
|
||||
<body><h1>Brainstorm Companion</h1>
|
||||
<p>Waiting for the agent to push a screen...</p></body></html>`;
|
||||
<body><!-- BRANDING --><h1>Brainstorm Companion</h1>
|
||||
<p>Waiting for the agent to push a screen...</p></body></html>`);
|
||||
}
|
||||
|
||||
const FORBIDDEN_PAGE = `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -189,13 +199,46 @@ const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function readSuperpowersVersion() {
|
||||
try {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../../..', 'package.json'), 'utf-8')
|
||||
);
|
||||
return String(packageJson.version || 'unknown');
|
||||
} catch (e) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function brandMarkup() {
|
||||
const version = escapeHtml(SUPERPOWERS_VERSION);
|
||||
const text = 'Superpowers v' + version + ' by Prime Radiant';
|
||||
const logo = SUPERPOWERS_TELEMETRY_DISABLED
|
||||
? ''
|
||||
: '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '?v=' + encodeURIComponent(SUPERPOWERS_VERSION) + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
|
||||
|
||||
return '<div class="brand"><a href="https://github.com/obra/superpowers">' + text + '</a>' + logo + '</div>';
|
||||
}
|
||||
|
||||
function renderBranding(html) {
|
||||
return html.replace('<!-- BRANDING -->', brandMarkup());
|
||||
}
|
||||
|
||||
function isFullDocument(html) {
|
||||
const trimmed = html.trimStart().toLowerCase();
|
||||
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
||||
}
|
||||
|
||||
function wrapInFrame(content) {
|
||||
return frameTemplate.replace('<!-- CONTENT -->', content);
|
||||
return renderBranding(frameTemplate).replace('<!-- CONTENT -->', content);
|
||||
}
|
||||
|
||||
function getNewestScreen() {
|
||||
@@ -341,7 +384,7 @@ function handleRequest(req, res) {
|
||||
const screenFile = getNewestScreen();
|
||||
let html = screenFile
|
||||
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
||||
: WAITING_PAGE;
|
||||
: waitingPage();
|
||||
|
||||
if (html.includes('</body>')) {
|
||||
html = html.replace('</body>', helperInjection + '\n</body>');
|
||||
|
||||
167
tests/brainstorm-server/branding.test.js
Normal file
167
tests/brainstorm-server/branding.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Tests for the visual companion's Superpowers/Prime Radiant branding.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '../..');
|
||||
const SERVER_PATH = path.join(REPO_ROOT, 'skills/brainstorming/scripts/server.cjs');
|
||||
const PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8')
|
||||
).version;
|
||||
const TOKEN = 'testtoken-branding-0123456789abcdef';
|
||||
const ASSET_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
|
||||
|
||||
function cleanup(dir) {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function startServer({ port, dir, disableTelemetry = false }) {
|
||||
cleanup(dir);
|
||||
return spawn('node', [SERVER_PATH], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAINSTORM_PORT: String(port),
|
||||
BRAINSTORM_DIR: dir,
|
||||
BRAINSTORM_TOKEN: TOKEN,
|
||||
...(disableTelemetry ? { SUPERPOWERS_DISABLE_TELEMETRY: '1' } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServer(server) {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
if (stdout.includes('server-started')) resolve();
|
||||
});
|
||||
server.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
server.on('error', reject);
|
||||
setTimeout(() => reject(new Error(`Server did not start. stderr: ${stderr}`)), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchHtml(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { Cookie: `brainstorm-key-${port}=${TOKEN}` };
|
||||
http.get(`http://localhost:${port}/`, { headers }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => { body += chunk; });
|
||||
res.on('end', () => resolve(body));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeFragment(dir) {
|
||||
const contentDir = path.join(dir, 'content');
|
||||
fs.mkdirSync(contentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(contentDir, 'screen.html'), '<h2>Pick a layout</h2>');
|
||||
}
|
||||
|
||||
async function withServer(options, fn) {
|
||||
const server = startServer(options);
|
||||
try {
|
||||
await waitForServer(server);
|
||||
await fn();
|
||||
} finally {
|
||||
server.kill();
|
||||
await sleep(100);
|
||||
cleanup(options.dir);
|
||||
}
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS: ${name}`);
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.log(` FAIL: ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function assertBranded(html) {
|
||||
assert(
|
||||
html.includes(`Superpowers v${PACKAGE_VERSION} by Prime Radiant`),
|
||||
'branding text should include dynamic package version'
|
||||
);
|
||||
}
|
||||
|
||||
function assertTelemetryImage(html) {
|
||||
const expectedUrl = `${ASSET_URL}?v=${encodeURIComponent(PACKAGE_VERSION)}`;
|
||||
assert(html.includes(`src="${expectedUrl}"`), 'remote image should use the dedicated main-domain asset with only v=');
|
||||
assert(!html.includes('event='), 'remote image URL must not include event=');
|
||||
assert(!html.includes('surface='), 'remote image URL must not include surface=');
|
||||
assert(!html.includes('launch_id='), 'remote image URL must not include launch_id=');
|
||||
assert(!html.includes('lid='), 'remote image URL must not include lid=');
|
||||
}
|
||||
|
||||
console.log('\n--- Visual Companion Branding ---');
|
||||
|
||||
test('framed screens render versioned Prime Radiant logo by default', async () => {
|
||||
const port = 3451;
|
||||
const dir = '/tmp/brainstorm-branding-default';
|
||||
await withServer({ port, dir }, async () => {
|
||||
writeFragment(dir);
|
||||
await sleep(300);
|
||||
const html = await fetchHtml(port);
|
||||
assertBranded(html);
|
||||
assertTelemetryImage(html);
|
||||
});
|
||||
});
|
||||
|
||||
test('waiting screen renders versioned Prime Radiant logo by default', async () => {
|
||||
const port = 3452;
|
||||
const dir = '/tmp/brainstorm-branding-waiting';
|
||||
await withServer({ port, dir }, async () => {
|
||||
const html = await fetchHtml(port);
|
||||
assert(html.includes('Waiting for the agent'), 'waiting page should still render');
|
||||
assertBranded(html);
|
||||
assertTelemetryImage(html);
|
||||
});
|
||||
});
|
||||
|
||||
test('SUPERPOWERS_DISABLE_TELEMETRY=1 omits remote image but keeps local branding', async () => {
|
||||
const port = 3453;
|
||||
const dir = '/tmp/brainstorm-branding-disabled';
|
||||
await withServer({ port, dir, disableTelemetry: true }, async () => {
|
||||
writeFragment(dir);
|
||||
await sleep(300);
|
||||
const html = await fetchHtml(port);
|
||||
assertBranded(html);
|
||||
assert(!html.includes(ASSET_URL), 'disabled telemetry should omit the remote image');
|
||||
});
|
||||
});
|
||||
|
||||
test('disabled telemetry also omits the remote image on the waiting screen', async () => {
|
||||
const port = 3454;
|
||||
const dir = '/tmp/brainstorm-branding-disabled-waiting';
|
||||
await withServer({ port, dir, disableTelemetry: true }, async () => {
|
||||
const html = await fetchHtml(port);
|
||||
assertBranded(html);
|
||||
assert(!html.includes(ASSET_URL), 'disabled telemetry should omit the remote image');
|
||||
});
|
||||
});
|
||||
|
||||
process.on('beforeExit', () => {
|
||||
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "brainstorm-server-tests",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "node ws-protocol.test.js && node helper.test.js && node browser-launcher.test.js && node auth.test.js && node server.test.js && node lifecycle.test.js && bash start-server.test.sh && bash stop-server.test.sh"
|
||||
"test": "node ws-protocol.test.js && node helper.test.js && node browser-launcher.test.js && node auth.test.js && node branding.test.js && node server.test.js && node lifecycle.test.js && bash start-server.test.sh && bash stop-server.test.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.19.0"
|
||||
|
||||
Reference in New Issue
Block a user