Skip to content
35 changes: 35 additions & 0 deletions .github/scripts/changelog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Finding a release's section in CHANGELOG.md.
*
* Both the pull-request check and the release publisher need the same answer,
* so the heading is recognised in one place. Matching is done on plain strings
* rather than by building a pattern out of the version: a version is data, and
* a pattern built from data is only ever as correct as its escaping.
*/

/** True when this line is the heading for exactly this version. A heading runs
* `## 6.4.0 — 2026-08-09`, so anything may follow the number as long as the
* number itself ends there — `## 6.4.01` is a different release. */
export const isHeadingFor = (line, version) => {
const heading = `## ${version}`
if (!line.startsWith(heading)) return false
const next = line.slice(heading.length)[0]
return next === undefined || !(next === "." || (next >= "0" && next <= "9"))
}

/** The line index of that heading, or -1. */
export const headingIndex = (lines, version) =>
lines.findIndex(line => isHeadingFor(line, version))

/**
* Everything under this version's heading, up to the next release heading.
* Null when the changelog has no entry for it.
*/
export const sectionFor = (changelog, version) => {
const lines = changelog.split("\n")
const start = headingIndex(lines, version)
if (start === -1) return null
const rest = lines.slice(start + 1)
const next = rest.findIndex(line => line.startsWith("## "))
return (next === -1 ? rest : rest.slice(0, next)).join("\n").trim()
}
4 changes: 2 additions & 2 deletions .github/scripts/check-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { headingIndex } from "./changelog.mjs"

const MANIFEST = "plugins/vstack/.claude-plugin/plugin.json"
const CHANGELOG = "CHANGELOG.md"
Expand Down Expand Up @@ -80,8 +81,7 @@ if (previous !== null && !isHigher(parse(declared, MANIFEST), parse(previous, `$
)
}

const heading = new RegExp(`^## ${declared.replace(/\./g, "\\.")}\\b`, "m")
if (!heading.test(readFileSync(CHANGELOG, "utf8"))) {
if (headingIndex(readFileSync(CHANGELOG, "utf8").split("\n"), declared) === -1) {
fail(
`${CHANGELOG} has no entry for ${declared}, and that entry is published as the release notes.`,
"",
Expand Down
16 changes: 5 additions & 11 deletions .github/scripts/publish-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { sectionFor } from "./changelog.mjs"

const MANIFEST = "plugins/vstack/.claude-plugin/plugin.json"
const CHANGELOG = "CHANGELOG.md"
Expand All @@ -32,23 +33,16 @@ try {
// No release under that tag yet, which is the case this runs for.
}

// Everything from this version's heading up to the next one. Written by a
// person, so it is published as-is rather than regenerated from commits.
const changelog = readFileSync(CHANGELOG, "utf8")
const heading = new RegExp(`^## ${version.replace(/\./g, "\\.")}\\b.*$`, "m")
const start = changelog.search(heading)
// Everything under this version's heading. Written by a person, so it is
// published as-is rather than regenerated from commits.
const notes = sectionFor(readFileSync(CHANGELOG, "utf8"), version)

if (start === -1) {
if (notes === null) {
console.error(`${CHANGELOG} has no entry for ${version}, so there are no notes to publish.`)
console.error("A pull request cannot merge without one, so this commit did not come through one.")
process.exit(1)
}

const rest = changelog.slice(start)
const nextRelease = rest.indexOf("\n## ", 1)
const section = (nextRelease === -1 ? rest : rest.slice(0, nextRelease)).trim()
const notes = section.slice(section.indexOf("\n") + 1).trim()

gh(
"release", "create", tag,
"--target", process.env.GITHUB_SHA,
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ The version in `plugins/vstack/.claude-plugin/plugin.json` is what your host
compares against to decide an update is available. See the release checklist in
[`CONTRIBUTING.md`](CONTRIBUTING.md).

## 6.4.1 — 2026-08-09

**Fixed**

- **A comment containing a double quote could break the page it was drawn on.**
The workspace escaped `&`, `<` and `>` but not quotes, and most of what it
builds is an HTML attribute — so a quote in your own words ended the attribute
early and the rest of the note was read as markup. Quotes are now escaped
everywhere the workspace writes them. The story map and the build board escape
the ids they put in attributes for the same reason.
- **A save could change every object in the review server, not just its own
comment.** A saved comment's fields were copied across by name, and a name
like `__proto__` reaches the prototype rather than the object. Those names are
now skipped.
- **The update check no longer keeps its cache in the shared temp directory.**
On a machine with more than one account, anyone could create that file first
and own what the check then wrote to it. It now lives in `~/.vstack/`, owned
by the reader and readable only by them. The move resets what the old cache
held, so a release you had already dismissed can ask once more.
- **A failed action shows what went wrong without the stack behind it.** The
message is the part a reader can act on and is still shown in full.
- **A second session starting at the same moment cannot reset the other's
counter.** The bridge's sequence file is now created in one step rather than
checked and then written.
- The phase-preview comparison reads `</script >` and `</style >` as the closing
tags they are, and strips nested comment markers until none are left.

## 6.4.0 — 2026-08-09

**Changed**
Expand Down
4 changes: 3 additions & 1 deletion e2e/support/world.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ export class ReviewWorld {
this.live = true
this.name = 'testapp'
const appPort = this.port + 1
const escapeHtml = s => String(s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
this.app = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html' })
res.end(`<!doctype html><title>Fixture</title><h1>${req.url}</h1><a href="/settings">Settings</a>`)
res.end(`<!doctype html><title>Fixture</title><h1>${escapeHtml(req.url)}</h1><a href="/settings">Settings</a>`)
})
await new Promise(resolve => this.app.listen(appPort, '127.0.0.1', resolve))
this.appOrigin = `http://127.0.0.1:${appPort}`
Expand Down
2 changes: 1 addition & 1 deletion plugins/vstack/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vstack",
"displayName": "Visual Stack",
"version": "6.4.0",
"version": "6.4.1",
"description": "Stop prompting. Start pointing. Visual Stack adds a Figma-like feedback layer to Claude Code. Create a new screen from a prompt, screenshot, reference site, or design system, or open an app you already have running. Click any element and leave feedback exactly where the problem is hiding, and Claude publishes the next revision into the same workspace. Compare revisions on a timeline, preview desktop, tablet, and mobile layouts, and keep every comment attached to the element, route, and version it refers to. Wireframes are self-contained HTML and review state stays in your project.",
"author": {
"name": "Cavalry Collective",
Expand Down
2 changes: 1 addition & 1 deletion plugins/vstack/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vstack",
"version": "6.4.0",
"version": "6.4.1",
"description": "Stop prompting. Start pointing. Visual Stack adds a Figma-like feedback layer to Codex. Create a new screen from a prompt, screenshot, reference site, or design system, or open an app you already have running. Click any element and leave feedback exactly where the problem is hiding, and Codex publishes the next revision into the same workspace. Compare revisions on a timeline, preview desktop, tablet, and mobile layouts, and keep every comment attached to the element, route, and version it refers to. Wireframes are self-contained HTML and review state stays in your project.",
"author": {
"name": "Cavalry Collective",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@
stacks in the order things entered it: one promoted before a dialog
would sit under it. Older browsers have no popover and lose nothing but
the stacking. */
try { el.showPopover() } catch {}
try { el.showPopover(); } catch {}
el.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
Expand Down Expand Up @@ -856,7 +856,7 @@
// `defaultLang` is what the artifact was authored in — it opens that way
// once, and after that the reader's own choice is the one that sticks.
if (opts.defaultLang && !storedLang) lang = opts.defaultLang === 'zh' ? 'zh' : 'en';
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang) }
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang); }
// Not every page sends something back — the form doesn't — so the primary
// action stays out of the bar unless a page asks for it.
const send = $('#send');
Expand Down Expand Up @@ -1077,7 +1077,7 @@
const all = layout();
const mono = doc.subjects[subject].mono ? 'mono' : '';
NODES().innerHTML = all.map(n => `
<div class="node ${n.state} ${mono} ${n.status ? 's-' + n.status : ''}" data-id="${esc(n.id)}" draggable="true"
<div class="node ${esc(n.state)} ${mono} ${n.status ? 's-' + esc(n.status) : ''}" data-id="${esc(n.id)}" draggable="true"
style="left:${n._x}px;top:${n._y}px">
${n.status === 'done' ? '<span class="badge">✓</span>'
: n.status === 'failed' ? '<span class="badge">!</span>'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,30 @@

// ── parsing ───────────────────────────────────────────────────────────────────

const stripComments = (html) => html.replace(/<!--[\s\S]*?-->/g, '')
/** One pass can leave a `<!--` behind, because removing a comment can join the
* text either side of it into a new one. Repeat until nothing more comes out. */
const stripComments = (html) => {
let out = html
for (let before = null; before !== out;) {
before = out
out = out.replace(/<!--[\s\S]*?-->/g, '')
}
return out
}

/* A closing tag may carry whitespace before its `>`. `</style >` ends a style
block just as `</style>` does, so the patterns below allow it — one that does
not would read the rest of the document as stylesheet. */

/** Concatenated contents of every <style> block, in document order. */
const styles = (html) =>
[...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n/*—*/\n')
[...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((m) => m[1]).join('\n/*—*/\n')

/** Blank out <script> and <style> bodies so their contents aren't read as markup. */
const stripRawText = (html) =>
html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '<script></script>')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '<style></style>')
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '<script></script>')
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, '<style></style>')

const OPEN_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g

Expand Down
6 changes: 3 additions & 3 deletions plugins/vstack/experimental/spec/assets/spec-tree.html
Original file line number Diff line number Diff line change
Expand Up @@ -951,7 +951,7 @@ <h1 id="hTitle" contenteditable="plaintext-only"></h1>
stacks in the order things entered it: one promoted before a dialog
would sit under it. Older browsers have no popover and lose nothing but
the stacking. */
try { el.showPopover() } catch {}
try { el.showPopover(); } catch {}
el.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
Expand Down Expand Up @@ -1085,7 +1085,7 @@ <h1 id="hTitle" contenteditable="plaintext-only"></h1>
// `defaultLang` is what the artifact was authored in — it opens that way
// once, and after that the reader's own choice is the one that sticks.
if (opts.defaultLang && !storedLang) lang = opts.defaultLang === 'zh' ? 'zh' : 'en';
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang) }
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang); }
// Not every page sends something back — the form doesn't — so the primary
// action stays out of the bar unless a page asks for it.
const send = $('#send');
Expand Down Expand Up @@ -1684,7 +1684,7 @@ <h1 id="hTitle" contenteditable="plaintext-only"></h1>
try {
v.json = JSON.stringify(await (await fetch(`${BRIDGE.history}/${n}?t=${BRIDGE.token}`)).json());
v.fetched = true;
} catch { syncTimeline(); return }
} catch { syncTimeline(); return; }
}
if (my !== navSeq || !v.json) return; // scrubbed past while it loaded
doc = migrate(JSON.parse(v.json));
Expand Down
5 changes: 4 additions & 1 deletion plugins/vstack/experimental/start/assets/chooser-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,12 @@ const server = http.createServer((req, res) => {
fs.writeFileSync(OUT, JSON.stringify(record, null, 2))
send(res, 200, 'application/json', '{"ok":true}')

// Names come from the page, and a line break in one would read as a
// second line of output that nothing here wrote.
const oneLine = s => String(s).replace(/[\r\n]+/g, ' ')
const what = record.skipDev ? 'specs & design only — development skipped'
: MODE === 'existing' ? 'existing project recorded'
: `${record.pack}` + (record.addons.length ? ` + ${record.addons.join(', ')}` : ' (no add-ons)')
: oneLine(record.pack) + (record.addons.length ? ` + ${record.addons.map(oneLine).join(', ')}` : ' (no add-ons)')
console.log(`\n✓ ${what}` +
(record.deleting.packs.length + record.deleting.addons.length
? `\n deleting ${record.deleting.packs.length} pack(s) and ${record.deleting.addons.length} add-on(s)` +
Expand Down
4 changes: 2 additions & 2 deletions plugins/vstack/experimental/start/assets/chooser.html
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ <h3><span class="dot"></span> <span id="cartTitle"></span></h3>
stacks in the order things entered it: one promoted before a dialog
would sit under it. Older browsers have no popover and lose nothing but
the stacking. */
try { el.showPopover() } catch {}
try { el.showPopover(); } catch {}
el.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
Expand Down Expand Up @@ -957,7 +957,7 @@ <h3><span class="dot"></span> <span id="cartTitle"></span></h3>
// `defaultLang` is what the artifact was authored in — it opens that way
// once, and after that the reader's own choice is the one that sticks.
if (opts.defaultLang && !storedLang) lang = opts.defaultLang === 'zh' ? 'zh' : 'en';
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang) }
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang); }
// Not every page sends something back — the form doesn't — so the primary
// action stays out of the bar unless a page asks for it.
const send = $('#send');
Expand Down
4 changes: 3 additions & 1 deletion plugins/vstack/lib/json-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ const someoneWatching = () => watchingRecently(WATCH_FILE)
const HIST_DIR = path.join(BRIDGE_DIR, STEM + '.history')
const HIST_INDEX = path.join(HIST_DIR, 'index.json')
fs.mkdirSync(BRIDGE_DIR, { recursive: true })
if (!fs.existsSync(SEQ_FILE)) fs.writeFileSync(SEQ_FILE, '0')
// Created only if it is not there, in one step: a second session starting at
// the same moment must not reset a counter the first one is already using.
try { fs.writeFileSync(SEQ_FILE, '0', { flag: 'wx' }) } catch {}
// A verdict belongs to the round that raised it — a new link starts unsigned,
// or the first waiter it arms fires on last week's approval.
fs.rmSync(APPROVED_FILE, { force: true })
Expand Down
4 changes: 2 additions & 2 deletions plugins/vstack/lib/shell/shell.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ window.VSShell = (function () {
stacks in the order things entered it: one promoted before a dialog
would sit under it. Older browsers have no popover and lose nothing but
the stacking. */
try { el.showPopover() } catch {}
try { el.showPopover(); } catch {}
el.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
Expand Down Expand Up @@ -315,7 +315,7 @@ window.VSShell = (function () {
// `defaultLang` is what the artifact was authored in — it opens that way
// once, and after that the reader's own choice is the one that sticks.
if (opts.defaultLang && !storedLang) lang = opts.defaultLang === 'zh' ? 'zh' : 'en';
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang) }
if (opts.lang) { lang = opts.lang === 'zh' ? 'zh' : 'en'; store.set(KEY.lang, lang); }
// Not every page sends something back — the form doesn't — so the primary
// action stays out of the bar unless a page asks for it.
const send = $('#send');
Expand Down
26 changes: 17 additions & 9 deletions plugins/vstack/lib/update-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,23 @@ const MARKET = 'cavalry-collective' // .claude-plugin/marketplace.json → nam
const PLUGIN = 'vstack' // the marketplace entry's name
const INSTALLS = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json')
const CODEX_CACHE = path.join(os.homedir(), '.codex', 'plugins', 'cache', MARKET, PLUGIN)
const CACHE = path.join(os.tmpdir(), 'vstack-update-check.json')
// The reader's own directory, not the shared temp dir: on a multi-user machine
// anyone can create a file there first and own what this then writes to.
const CACHE_DIR = path.join(os.homedir(), '.vstack')
const CACHE = path.join(CACHE_DIR, 'update-check.json')
const TTL_MS = 6 * 60 * 60 * 1000
const TIMEOUT_MS = 2500

const readJSON = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8')) } catch { return null } }

/** Merge into the cache, or carry on without it. Nothing here is worth failing
* a server start for: the cache only saves a question being asked again. */
const writeCache = fields => {
try {
fs.mkdirSync(CACHE_DIR, { recursive: true })
fs.writeFileSync(CACHE, JSON.stringify({ ...(readJSON(CACHE) || {}), ...fields }), { mode: 0o600 })
} catch {}
}
const short = sha => String(sha || '').slice(0, 7)

/** Whatever plugin.json still declares — normally nothing, by design. */
Expand Down Expand Up @@ -142,9 +154,7 @@ async function ask (kind) {
// Cache the answer either way: a repo that has not moved should not be
// asked again every time a server starts. Merged rather than replaced —
// `met` is a different question and outlives any one answer.
try {
fs.writeFileSync(CACHE, JSON.stringify({ ...(readJSON(CACHE) || {}), at: Date.now(), kind, value }))
} catch {}
writeCache({ at: Date.now(), kind, value })
return value
} catch {
return cached?.kind === kind ? cached.value ?? null : null
Expand All @@ -171,9 +181,8 @@ const say = (key, title) => ({ pill: 'update', key, title })
* starts empty every time and a first sighting would be all there ever was.
*/
function firstSighting (key) {
const cache = readJSON(CACHE) || {}
if (cache.met === key) return false
try { fs.writeFileSync(CACHE, JSON.stringify({ ...cache, met: key })) } catch {}
if ((readJSON(CACHE) || {}).met === key) return false
writeCache({ met: key })
return true
}

Expand All @@ -188,8 +197,7 @@ function firstSighting (key) {
*/
export function dismissUpdate (key) {
if (!key) return
const cache = readJSON(CACHE) || {}
try { fs.writeFileSync(CACHE, JSON.stringify({ ...cache, seen: String(key) })) } catch {}
writeCache({ seen: String(key) })
}

/**
Expand Down
2 changes: 1 addition & 1 deletion plugins/vstack/skills/review/assets/harvest-reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
// reports `currentColor`, which would file every text colour as a border.
for (const side of ['Top', 'Bottom', 'Left']) {
const w = px(c['border' + side + 'Width']);
if (w && c['border' + side + 'Style'] !== 'none') { bump('border', `${w}px ${c['border' + side + 'Color']}`, r.width); break }
if (w && c['border' + side + 'Style'] !== 'none') { bump('border', `${w}px ${c['border' + side + 'Color']}`, r.width); break; }
}
if (px(c.borderTopLeftRadius)) bump('radius', c.borderTopLeftRadius, area);
if (c.boxShadow !== 'none') bump('shadow', c.boxShadow, area);
Expand Down
Loading