Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
989 changes: 989 additions & 0 deletions docs/superpowers/plans/2026-05-08-visual-companion-alpine.md

Large diffs are not rendered by default.

465 changes: 465 additions & 0 deletions docs/superpowers/specs/2026-05-08-visual-companion-alpine-design.md

Large diffs are not rendered by default.

51 changes: 49 additions & 2 deletions scripts/sync-to-codex-plugin.sh
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,53 @@ fi

git add "$DEST_REL"

vendor_notice_for_pr_body() {
local provenance_glob="$DEST"/skills/*/scripts/vendor/*.provenance.json

if ! compgen -G "$provenance_glob" > /dev/null; then
return 0
fi

command -v python3 >/dev/null || die "python3 not found in PATH"
python3 - "$DEST" <<'PY'
import glob
import json
import os
import sys

dest = sys.argv[1]
provenance_files = sorted(glob.glob(os.path.join(dest, "skills", "*", "scripts", "vendor", "*.provenance.json")))
if not provenance_files:
raise SystemExit(0)

print()
print()
print("Vendored third-party code included in this sync:")
for provenance_file in provenance_files:
with open(provenance_file, "r", encoding="utf-8") as fh:
provenance = json.load(fh)

rel_provenance = os.path.relpath(provenance_file, dest)
rel_vendor_dir = os.path.dirname(rel_provenance)
basename = os.path.basename(provenance_file)
suffix = ".provenance.json"
if basename.endswith(suffix):
basename = basename[:-len(suffix)]
local_path = provenance.get("localPath") or os.path.join(rel_vendor_dir, f"{basename}.js")
notice_path = os.path.join(rel_vendor_dir, "THIRD_PARTY_NOTICES.md")
name = provenance.get("name", "unknown")
version = provenance.get("version", "unknown")
approval = provenance.get("approvalArtifact", "not recorded")
sha256 = provenance.get("sha256", "not recorded")

print(f"- `{local_path}`: {name} {version}")
print(f" - Approval artifact: {approval}")
print(f" - License notice: `{notice_path}`")
print(f" - Provenance: `{rel_provenance}`")
print(f" - SHA256: `{sha256}`")
PY
}

if [[ $BOOTSTRAP -eq 1 ]]; then
COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
Expand All @@ -424,7 +471,7 @@ Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstre
Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA

This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files."
This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files.$(vendor_notice_for_pr_body)"
else
COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT"
PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION).
Expand All @@ -434,7 +481,7 @@ Copies the tracked plugin files from upstream, including the committed Codex man
Run via: \`scripts/sync-to-codex-plugin.sh\`
Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA

Running the sync tool again against the same upstream SHA should produce a PR with an identical diff — use that to verify the tool is behaving."
Running the sync tool again against the same upstream SHA should produce a PR with an identical diff — use that to verify the tool is behaving.$(vendor_notice_for_pr_body)"
fi

git commit --quiet -m "$COMMIT_TITLE
Expand Down
3 changes: 2 additions & 1 deletion skills/brainstorming/scripts/frame-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@
.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>
<script defer src="/vendor/alpine.js"></script>
</head>
<body>
<div class="header">
Expand All @@ -207,7 +208,7 @@ <h1><a href="https://github.com/obra/superpowers" style="color: inherit; text-de
</div>

<div class="indicator-bar">
<span id="indicator-text">Click an option above, then return to the terminal</span>
<span id="indicator-text">Interact with the mockup, then return to the terminal</span>
</div>

</body>
Expand Down
2 changes: 1 addition & 1 deletion skills/brainstorming/scripts/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
const container = target.closest('.options') || target.closest('.cards');
const selected = container ? container.querySelectorAll('.selected') : [];
if (selected.length === 0) {
indicator.textContent = 'Click an option above, then return to the terminal';
indicator.textContent = 'Interact with the mockup, then return to the terminal';
} else if (selected.length === 1) {
const label = selected[0].querySelector('h3, .content h3, .card-body h3')?.textContent?.trim() || selected[0].dataset.choice;
indicator.innerHTML = '<span class="selected-text">' + label + ' selected</span> — return to terminal to continue';
Expand Down
47 changes: 44 additions & 3 deletions skills/brainstorming/scripts/server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ h1 { color: #333; } p { color: #666; }</style>
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>';
const ALPINE_VENDOR_PATH = path.join(__dirname, 'vendor', 'alpine.js');

function loadVendorFile(filePath, name) {
try {
return fs.readFileSync(filePath);
} catch (error) {
throw new Error(
`Failed to load vendored ${name} at ${filePath}; ` +
'run the refresh command in skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md. ' +
error.message
);
}
}

const VENDOR_FILES = new Map([
['/vendor/alpine.js', {
content: loadVendorFile(ALPINE_VENDOR_PATH, 'Alpine'),
contentType: 'application/javascript; charset=utf-8'
}]
]);

// ========== Helper Functions ==========

Expand All @@ -124,11 +144,30 @@ function getNewestScreen() {
return files.length > 0 ? files[0].path : null;
}

function parseRequestUrl(req) {
// Vendor routing depends on URL normalization before exact pathname allowlist checks.
return new URL(req.url, 'http://localhost');
}

function serveVendorFile(requestUrl, res) {
const vendorFile = VENDOR_FILES.get(requestUrl.pathname);
if (!vendorFile) {
res.writeHead(404);
res.end('Not found');
return;
}

res.writeHead(200, { 'Content-Type': vendorFile.contentType });
res.end(vendorFile.content);
}

// ========== HTTP Request Handler ==========

function handleRequest(req, res) {
touchActivity();
if (req.method === 'GET' && req.url === '/') {
const requestUrl = parseRequestUrl(req);

if (req.method === 'GET' && requestUrl.pathname === '/') {
const screenFile = getNewestScreen();
let html = screenFile
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
Expand All @@ -142,8 +181,10 @@ function handleRequest(req, res) {

res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} else if (req.method === 'GET' && req.url.startsWith('/files/')) {
const fileName = req.url.slice(7);
} else if (req.method === 'GET' && requestUrl.pathname.startsWith('/vendor/')) {
serveVendorFile(requestUrl, res);
} else if (req.method === 'GET' && requestUrl.pathname.startsWith('/files/')) {
const fileName = requestUrl.pathname.slice(7);
const filePath = path.join(CONTENT_DIR, path.basename(fileName));
if (!fs.existsSync(filePath)) {
res.writeHead(404);
Expand Down
48 changes: 48 additions & 0 deletions skills/brainstorming/scripts/vendor/THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Third-Party Notices

## Alpine.js

- Package: `alpinejs`
- Version: `3.15.12`
- Source: `https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz`
- Vendored file: `package/dist/cdn.min.js`
- Local path: `skills/brainstorming/scripts/vendor/alpine.js`
- SHA256: `57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f`

Refresh command:

```bash
cd "$(git rev-parse --show-toplevel)"
tmpdir="$(mktemp -d)"
curl -fsSL https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz -o "$tmpdir/alpinejs-3.15.12.tgz"
tar -xzf "$tmpdir/alpinejs-3.15.12.tgz" -C "$tmpdir" package/dist/cdn.min.js
cp "$tmpdir/package/dist/cdn.min.js" skills/brainstorming/scripts/vendor/alpine.js
shasum -a 256 skills/brainstorming/scripts/vendor/alpine.js
rm -rf "$tmpdir"
```

License:

```text
MIT License
Copyright © 2019-2025 Caleb Porzio and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
5 changes: 5 additions & 0 deletions skills/brainstorming/scripts/vendor/alpine.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions skills/brainstorming/scripts/vendor/alpine.provenance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "alpinejs",
"version": "3.15.12",
"license": "MIT",
"sourceUrl": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz",
"sourcePackagePath": "package/dist/cdn.min.js",
"localPath": "skills/brainstorming/scripts/vendor/alpine.js",
"npmIntegrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==",
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
"approvalArtifact": "SUP-215",
"vendoredAt": "2026-05-08"
}
61 changes: 54 additions & 7 deletions skills/brainstorming/visual-companion.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A question *about* a UI topic is not automatically a visual question. "What kind

## How It Works

The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn.
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user tries the mockup in their browser, and they respond in the terminal. Use `[data-choice]` only when you are deliberately asking the user to pick among named A/B/C visual options.

**Content fragments vs full documents:** If your HTML file starts with `<!DOCTYPE` or `<html`, the server serves it as-is (just injects the helper script). Otherwise, the server automatically wraps your content in the frame template — adding the header, CSS theme, selection indicator, and all interactive infrastructure. **Write content fragments by default.** Only write full documents when you need complete control over the page.

Expand Down Expand Up @@ -103,8 +103,9 @@ Use `--url-host` to control what hostname is printed in the returned URL JSON.

2. **Tell user what to expect and end your turn:**
- Remind them of the URL (every step, not just first)
- Give a brief text summary of what's on screen (e.g., "Showing 3 layout options for the homepage")
- Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like."
- Give a brief text summary of what's on screen (e.g., "Showing an interactive meal-planning mockup with tabs and an editable grocery list")
- Ask them to respond in the terminal: "Take a look, try the mockup, and tell me what feels right or wrong."
- If the screen is a deliberate A/B/C choice, also say: "Click an option if you'd like; your terminal feedback is still the source of truth."

3. **On your next turn** — after the user responds in the terminal:
- Read `$STATE_DIR/events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines
Expand All @@ -130,6 +131,48 @@ Use `--url-host` to control what hostname is printed in the returned URL JSON.

Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, selection indicator, and all interactive infrastructure).

## Interactive Mockups With Alpine

Frame-wrapped fragments automatically load Alpine.js. Use Alpine when visible interaction is central to the design question: tabs, toggles, accordions, modal open/close, wizard next/back, lightweight form validation, or simple add/remove list behavior.

Keep it illustrative. Do not build a fake application just because realistic chrome includes many controls. If an interaction is not part of the question, render that area as passive content.

```html
<div x-data="{ tab: 'week', items: [{ id: 1, label: 'Taco night' }, { id: 2, label: 'Soup prep' }], nextId: 3, newItem: '' }">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<button class="mock-button" @click="tab = 'week'">Week</button>
<button class="mock-button" @click="tab = 'list'">Grocery list</button>
</div>

<section x-show="tab === 'week'">
<h3>Week plan</h3>
<p class="subtitle">Three realistic meals are enough for the mockup.</p>
</section>

<section x-show="tab === 'list'">
<h3>Grocery list</h3>
<ul>
<template x-for="item in items" :key="item.id">
<li x-text="item.label"></li>
</template>
</ul>
<input class="mock-input" x-model="newItem" placeholder="Add item">
<button class="mock-button" @click="if (newItem.trim()) { items.push({ id: nextId++, label: newItem.trim() }); newItem = '' }">Add</button>
</section>
</div>
```

Rules:

- Write content fragments by default; do not add an Alpine `<script>` tag.
- Generate 2-5 compact, realistic records for the user's domain. Put records in `x-data` only when interaction needs state.
- Use stable ids for repeatable records; do not key dynamic lists by user-entered labels.
- Keep terminal feedback primary. Alpine interactions are for understanding, not telemetry.
- Use `data-choice` only for deliberate named options the agent should read next turn.
- Use `@click.stop` or separate controls when an Alpine control is near a `[data-choice]` surface.
- Do not call `fetch`, simulate backend writes, or use `localStorage` / `sessionStorage`.
- Do not load live network images. Use local `/files/<basename>` assets when the project provides them, or use a simple local placeholder.

**Minimal example:**

```html
Expand Down Expand Up @@ -160,7 +203,9 @@ That's it. No `<html>`, no CSS, no `<script>` tags needed. The server provides a

The frame template provides these CSS classes for your content:

### Options (A/B/C choices)
### Deliberate Options (A/B/C choices)

Use these only when you want a structured choice event. Do not wrap ordinary Alpine controls in `[data-choice]`.

```html
<div class="options">
Expand All @@ -182,7 +227,9 @@ The frame template provides these CSS classes for your content:
</div>
```

### Cards (visual designs)
### Deliberate Cards (visual design choices)

Use `[data-choice]` cards for visual alternatives, not for normal clickable app UI.

```html
<div class="cards">
Expand Down Expand Up @@ -246,7 +293,7 @@ The frame template provides these CSS classes for your content:

## Browser Events Format

When the user clicks options in the browser, their interactions are recorded to `$STATE_DIR/events` (one JSON object per line). The file is cleared automatically when you push a new screen.
When the user clicks deliberate `[data-choice]` options in the browser, those selections are recorded to `$STATE_DIR/events` (one JSON object per line). Ordinary Alpine interactions such as tabs, toggles, forms, and modals are not recorded. The file is cleared automatically when you push a new screen, so each screen starts with a clean event log. The terminal message remains the primary feedback.

```jsonl
{"type":"click","choice":"a","text":"Option A - Simple Layout","timestamp":1706000101}
Expand All @@ -264,7 +311,7 @@ If `$STATE_DIR/events` doesn't exist, the user didn't interact with the browser
- **Explain the question on each page** — "Which layout feels more professional?" not just "Pick one"
- **Iterate before advancing** — if feedback changes current screen, write a new version
- **2-4 options max** per screen
- **Use real content when it matters** — for a photography portfolio, use actual images (Unsplash). Placeholder content obscures design issues.
- **Use local assets when images matter** — if the project has relevant images, reference them through `/files/<basename>`. Do not pull images from remote URLs just to make a mockup feel polished.
- **Keep mockups simple** — focus on layout and structure, not pixel-perfect design

## File Naming
Expand Down
Loading