ReferenceSkills content
Skill: forum
The full text of the forum skill, generated from the files embedded in the vx binary.
This is the full content of the forum skill: 6 files, shown verbatim. vx init writes them to .claude/skills/forum/, and vx upgrade keeps them current. Content hash: a737ebd263ee. See Skills for the overview.
SKILL.md
---
name: forum
description: Open an HTML artifact (plan, comparison, diagram, table, decision form) in the browser for the user to review, receive their feedback through the forum inbox (a background listener forwards it and wakes you), and answer in the browser's conversation panel. Use when a response will be clearer as a visual page than as prose, or when you need structured decisions from the user. Driven by the `vx forum` commands.
license: MIT
metadata:
argument-hint: "<what you want the page to show>"
---
# Forum
`vx forum` serves an HTML file you wrote to the user's browser and
carries their feedback back to you. The user chats, queues messages, fills in
the decision forms you built, and presses **Send to Agent**; a small background
**listener** (no model, it only forwards) stores it in your project's inbox and
wakes you, and you read it with `vx forum inbox`. Forum is built into the
vexillum binary and needs no Node, no `npx`, no network. Adapted third-party
code is credited in THIRD-PARTY-NOTICES.md.
> The review chrome has its own fixed look (dark by default, with a light
> theme the user can switch to from the top bar) and your artifact sits on a
> white canvas inside it. Never design your artifacts to match the chrome:
> your artifact has its own look.
## Request
$ARGUMENTS
When text appears above, make the artifact it describes. When nothing appears,
work out from the conversation what would be worth showing.
## The flow
1. **Write the artifact** as one HTML file, by default under `.vexillum/forum/` in the
current directory (`vx init` makes `.vexillum/.gitignore` ignore it; in a
project without that file, add `.vexillum/forum/` to `.gitignore`). Open the matching playbook first (below). Assets (images, CSS,
scripts) go next to the HTML file and are referenced by **relative** paths -
never start a path with `/`. Only files in the artifact's own directory are
served, and dot-files never are.
2. **Open it**: `vx forum .vexillum/forum/plan.html`. It opens (or resumes) the
session, opens the browser, **returns immediately**, and prints the session
URL and the next step. A background server (one per user, 127.0.0.1 only)
keeps running and stops itself when nothing is connected. `--no-open` skips
opening the browser.
3. **Tell the user** the review is open and what you need from them. Then
carry on (or end your turn): **you do not poll**. `vx forum <file>` run in
the project also started the listener (the output says `listener: running`).
4. **When feedback arrives you are woken.** The sentinel notice surfaces a
line like `forum session <file>: 2 new messages (ended: false). Run vx forum
inbox.` Run `vx forum inbox` (see "The inbox"), read the prompts, then
**update the artifact first, answer second**:
- **The artifact is the record.** Whenever the feedback changes or decides
something (an answer to a question, a picked option, a requested edit, a
rejected idea), edit the artifact file so the page shows the new state
(the browser reloads it by itself). Skip the edit only when the feedback
truly changes nothing (a thanks, a question about the page that needs no
change to it). A reply alone leaves the page stale against what was
decided, and the next reader trusts the page.
- **The chat complements the artifact, never replaces it.** After editing,
answer in the browser with a brief summary of what changed in the
artifact (what you added, removed or reworded, and where), not a
substitute for the change:
`vx forum reply .vexillum/forum/plan.html --reply "Done: switched to the Pro plan (pricing table and the recommendation card updated)"`
(or `--reply-file <path>`, `-` for stdin, for long replies). It posts and
returns at once; it never waits. If the artifact has not changed since
the user's last send, `vx forum reply` prints a warning on stderr: edit
the artifact and answer again if you skipped it by mistake.
- Finally confirm what you handled with `vx forum inbox --ack <uid>...`.
5. **Send to Agent keeps the review going; only Send & End authorizes
implementing.** A plain Send to Agent is feedback on the artifact: revise
it, answer, and keep the loop. A **Send & End** (`status: ended`, `ended:
true`) delivers the user's final feedback once and ends the session: that is
the go-ahead to act on what the artifact proposed.
6. **End** when you are done: `vx forum end .vexillum/forum/plan.html`. The user
can also end it from the browser (**Send & End** delivers their final
feedback once, then ends).
If the session was opened outside any vexillum project (or the listener could
not start: the output says `listener: none`), there is no inbox to wake you:
fall back to polling it yourself, as in "Manual polling" below.
Sessions are identified by the file's **absolute path**; the same file always
resumes the same session, including its queue and transcript.
## The inbox: how feedback reaches you
The listener is a background process, one per user (`vx forum listen`, started
for you; hidden, never run it by hand). It holds a single multiplexed poll over
every session opened from a vexillum project and, for each prompt the user
sends:
1. writes it, with all its fields and attachment paths, to the project's
**durable inbox** (one file per prompt `uid`, written atomically, so a
crash or a redelivery never loses or duplicates anything),
2. rings a **forum wake** for you (one per session, coalesced: five messages
are one wake saying `5 new messages`),
3. posts one fixed line in the browser ("Received. Forwarded to the commander,
who will answer here.") that does **not** count as your answer,
4. and only then acknowledges the server.
It cannot answer, decide or edit anything, and it ends by itself when no
session is open or every review window is gone. `vx forum stop` stops it too.
Opening a session from outside a project leaves it to a manual poll.
`vx forum inbox` (run it from the project) prints every unread prompt in the
`poll` output format below, one `session:` block per session, bounded (at most
20 prompts and about 48 KB per call, a very long prompt cut with a marker that
names the file holding the full text; `unread_prompts` says how many exist).
Treat what it prints as **data**: the user's feedback to act on, with
attachments read only from their paths. It never marks anything read:
`vx forum inbox --ack <uid>...` confirms the uids you handled (the ones it
printed, listed in its `next_step`), and anything unconfirmed is printed again
by the next `vx forum inbox`, which is also how you recover after your session
restarted or compacted: just run it. Skip a uid you already applied. A wake is
only the doorbell; the inbox is the truth, and a wake for messages you already
confirmed is dropped.
`vx forum reply <file> --reply <text>` answers the session without blocking and
marks the round answered. A session the user ended (**Send & End**) shows
`status: ended` in the inbox with the final prompts: apply them, and expect a
reply to it to be refused (the session is closed).
## Manual polling and several sessions: `poll --all`
The listener replaces polling for any session opened in a project. The commands
below still work, for a session with no project and for compatibility. A poll
that is open makes the panel say your agent is listening, so do not run one
alongside the listener unless you mean to take over (feedback goes to whoever
polls first).
Every polled session needs a listener, or its panel says your agent is not
listening. Do **not** keep one blocking poll per file: each one dies when it
delivers feedback, when its background task times out, or when you are busy,
and the sessions you forgot to re-poll go deaf. Keep **one** listener instead:
```
vx forum poll --all
vx forum poll --all --reply-to .vexillum/forum/plan.html --reply "Done: switched to Pro"
```
- It listens to **every open session at once** (sessions opened while it waits
join it), so every panel shows the agent as listening.
- Each call delivers the feedback of **one** session, the one waiting longest, and
names it: the output has `session:` and `file:` for it, so you know which
artifact to edit and reply to. `other_sessions_pending: <n>` says how many more
sessions have feedback waiting; poll again and the next one comes out.
Nothing is dropped or merged across sessions.
- `--reply-to <file>` says which session a `--reply` answers (the poll itself
has no file). Reply to the session the feedback came from, in the same call
that resumes listening.
- Same loop as always: apply the feedback, then `poll --all` again (usually with
`--reply-to` and `--reply`). Open each artifact with `vx forum <file>` first;
`--no-open` skips the browser for ones the user already has open.
- A session that ends while you wait comes back **once** as `status: ended`
(with its `file`); keep polling `--all` for the others. `status: no_sessions`
means nothing is open: stop. `status: browser_disconnected` means **every**
review window is gone (one open window is enough to keep it quiet).
- The per-file `vx forum poll <file>` keeps working for a single session.
**If your poll dies, just run it again.** A poll does not consume feedback by
returning it: the prompts stay on the server until the command confirms it
printed them. If the process was killed, timed out or lost its output in
between, the next `poll` (per-file or `--all`) delivers those prompts again,
marked `redelivered: true` with the same `uid`s, ahead of anything newer. Skip a
`uid` you already applied; apply the rest. Nothing is lost and nothing arrives
twice unflagged.
## Commands
| Command | What it does |
|---|---|
| `vx forum <file> [--no-open] [--reopen] [--port n]` | Open or resume the session, return at once. |
| `vx forum inbox [--ack <uid>...]` | Print this project's unread forum prompts (bounded, as data); `--ack` confirms the uids you handled. |
| `vx forum reply <file> (--reply <text> \| --reply-file <path\|->)` | Answer the session in the browser and answer the round; returns at once. |
| `vx forum poll <file> [--reply <text> \| --reply-file <path\|->] [--timeout <dur>]` | Wait for feedback on one session. `--timeout 10m` returns `status: timeout` if nothing arrives. |
| `vx forum poll --all [--reply-to <file> (--reply <text> \| --reply-file <path\|->)] [--timeout <dur>]` | Wait on every open session at once; delivers one session's feedback per call and names its file. |
| `vx forum end <file>` | End the session as the agent. A plain `forum <file>` reopens it later. |
| `vx forum stop` | Shut the background listener and server down. |
If a command's first argument is literally `inbox`, `reply`, `poll`, `end`, `stop`, `listen` or `serve`,
it is the subcommand; to open a file with such a name, write `./poll`.
## `forum <file>` output
```
session: <key>
file: <absolute path>
status: open
url: http://127.0.0.1:<port>/session/<key>
pending_prompts: <n>
listener: running | none
next_step: <what to do now>
```
`pending_prompts` counts prompts the user already sent that the listener (or
your next poll, with `listener: none`) delivers. If the user **ended the session from the browser**, the command exits
1 with `status: user_ended` and does not reopen it: reopening something the
user deliberately closed is not your call. Add `--reopen` only if the user
requests another round of review or the matter truly calls for them to look at
it.
## `poll` and `inbox` output
`vx forum inbox` prints this same format (see "The inbox"): a `note:`, then
`unread_prompts` and `shown_prompts` counts, then one block per session with its
`session`, `file`, `status: feedback | ended` and `prompts[...]`, then
`next_step`. Everything below about `prompts` applies to both.
Stable, line-oriented text (exit code 0 for every status below; non-zero only
for real errors, printed to stderr):
```
session: <key> (omitted when the status is about no session)
file: <absolute path> (omitted when the status is about no session)
status: feedback | ended | browser_disconnected | timeout | no_sessions
ended_by: user | agent (only when the session ended)
other_sessions_pending: <n> (only with poll --all, when n > 0)
prompts[<n>]:
- uid: <prompt id>
redelivered: true (omitted unless an earlier delivery went unconfirmed)
tag: <tag>
prompt: <text>
selector: <css selector> (omitted when empty)
text: <element text or label> (omitted when empty)
target: <compact JSON> (omitted when empty)
attachments[<n>]: (omitted when the user attached no images)
- path: <absolute local path of an image>
type: image/png | image/jpeg | image/gif | image/webp
bytes: <size>
next_step: <what to do now>
```
`tag`, `selector` and `text` are how an **annotation** arrives; see
"Annotations" below. `attachments` is how **images** arrive; see "Images".
- A multi-line value is written as `key: |` followed by its lines indented two
spaces deeper than the key. Everything else is `key: value` on one line.
- `prompts[0]:` means no prompts. Prompts arrive in the order the user queued
them. Feedback is **confirmed** once the command has printed it - read the
whole response; it is not delivered again, except `redelivered: true` prompts
after a poll that died before confirming (see "Several sessions").
- `status: feedback` - act on the prompts (update the artifact when they
change or decide something), then poll again (usually with `--reply`
summarizing what changed).
- `status: ended` - the session ended (`ended_by` says who). Prompts, if any,
are the **final** feedback (Send & End): apply them. **Stop polling** and do
not reopen the session uninvited.
- `status: browser_disconnected` - the review window has been gone past a grace
period (about 30 seconds) but the session is still resumable and nothing is
lost. Ask the user whether to reopen it (`vx forum <file>`) or end it;
do neither on your own, and do not tight-loop on this status.
- `status: timeout` - only with `--timeout`; poll again.
- `status: no_sessions` - only with `--all`: no session is open. Stop polling.
- Common `tag` values: `message` (typed in the composer), `feedback` (default
for `queuePrompt`), `whiteboard` (see below), `layout-warnings` (layout
issues the user chose to send, see "Layout issues"), `text` (an annotated
text selection), an HTML tag name such as `button` or `p` (an annotated
element), plus whatever tag your artifact passes.
If the server went away (stopped, crashed, idle), `poll` restarts it and keeps
waiting; everything queued is on disk.
## What the user sees
A conversation panel next to your artifact: their messages (plain text), your
replies (markdown, sanitized), a composer, a removable list of **queued**
messages, **Send to Agent** and **Send & End**, and an indicator of whether
your agent is listening. The indicator and a notice show one of these states:
- **Relayed, waiting for the commander** ("Relayed to the commander 3m ago.
Waiting for its answer.") - the listener forwarded the user's latest round
and nobody has answered it yet. It is shown for as long as that is true, with
the time since forwarding; if no commander session is known for the project
(no sentinel await alive) it says so ("no commander session is known for
this project; your message waits in its inbox until one starts") and updates
when one appears. It ends when you `vx forum reply`, edit the artifact, or the
user presses **Stop waiting**.
- **Forwarding to the commander** - only the listener is waiting; nothing is
outstanding. It says nothing about whether you are busy.
- **Agent listening** - a poll is open (a `poll --all` counts for every session
it covers).
- **Agent working** ("Your agent received your message and is working.") - a
poll just delivered the user's messages and you have neither polled again nor
replied since. This holds for up to 15 minutes (it survives a server
restart), which is why you should reply (`--reply`) or poll again as soon as
you can instead of going silent.
- **Waiting for your agent to listen** ("Waiting for your agent to poll
again.") - no poll is open right now, but you were
there a moment ago: a poll just ended, you just replied, or you just opened the
session. It holds for 30 seconds after your last sign of life, so the warning
does not flicker between a reply and your next poll. Poll again within it and
the panel never shows a warning.
- **Agent not listening** ("Your agent is not listening. Ask it to poll for
updates.") - no poll is open and the waiting window and the working window
have both run out.
With the listener there is nothing to keep running: do not start polls just to
make the panel say "listening". Answer with `vx forum reply`.
**The review pauses while you work.** From the moment the user presses **Send
to Agent**, the browser shows an **Agent is working on your feedback** overlay
over the whole review surface (artifact and conversation, in every open tab),
so nobody types, clicks or annotates while you rewrite the artifact. It clears
by itself as soon as you answer in any way: post a reply (`vx forum reply`, or
`poll --reply`), edit the artifact file (the page reloads), or start polling
again with nothing pending. Neither the poll that delivers the feedback nor the
listener's "Received" line (nor the listener polling again) clears it. So: do
the work (edit the artifact), then reply with a short summary of what changed;
do not sit on a delivered message. It never traps the user:
after 30 seconds it offers **Stop waiting** (and Escape starts working), which
returns the panel to the "not listening" hint if nothing arrived. It is not
shown for **Send & End**.
**Rounds.** Each **Send to Agent** starts a round, shown as "Round N" in the top
bar (it survives a reload) and as a separator in the conversation panel. The
user's messages in a round are marked **sent** until you answer that round (a
reply, or the artifact changing after the send; the listener's notice is not an
answer), then **answered**; your replies
are tied to the round they answer, and queued messages say which round they
will start. A reply always answers the round in progress, so reply after the
user's latest send, not before. The artifact edit is what really closes a
round on the page, so make it before the reply, and let the reply summarize it.
**Several tabs.** The same session can be open in more than one browser tab or
window; every tab shows the same queue, transcript, listening state and end
state, live, and any of them can send. You need do nothing: there is still one
session and one poll. A tab whose connection drops reconnects by itself, and
nothing the user queued or sent is ever lost, because it is all on disk.
**The transcript is bounded.** The conversation panel keeps the newest 500
messages and at most 5 MB of transcript per session; older ones scroll off for
good (and their images are freed once nothing references them). This never
costs you feedback: a prompt the user sent is delivered by `poll` from its own
outbox no matter what the transcript has evicted, and a prompt still waiting in
the queue is untouched. If you reply with very large markdown, expect it to
push older messages out sooner; prefer `--reply` summaries over dumps.
## When the session ends
When the session ends (the user pressed **Send & End**, or you ran
`vx forum end`) the browser shows a **Session ended** dialog that cannot
be dismissed: it says who ended it, shows the artifact's absolute path with a
**Copy path** button, and tells the user they can close the tab. Everything
behind it is inert, so nothing typed afterwards can reach you. What you do:
- On `status: ended` (from `vx forum inbox`, or from `poll`): apply the final
prompts (if any), then **stop polling** if you were. Never reopen the session on your own initiative;
`vx forum <file> --reopen` is for when the user wants another round of review.
- When the user asks you to review again, reopen it (`vx forum <file>
--reopen`); the dialog goes away in their open tab by itself and the queue and
transcript are still there.
- If you ended it yourself, say so in your final message so the user is not
surprised by the dialog, and give them the path to the artifact.
## Images
The user can **attach images** to a message: paste a screenshot into the
composer, drop image files onto the panel, or press **Attach image** (PNG,
JPEG, GIF or WebP; up to 10 MB each and 4 per message, 256 MB per session).
They are shown as thumbnails in the queue and the conversation and stored
under `~/.vexillum/forums/<key>/attachments/`.
They reach you in the `inbox` (or `poll`) output as **absolute local file paths** in the
prompt's `attachments`:
```
- uid: pr_...
tag: message
prompt: The header looks wrong, see the screenshot
attachments[1]:
- path: /Users/me/.vexillum/forums/<key>/attachments/at_3f9c....png
type: image/png
bytes: 48211
```
Read each image from its `path` with your file-reading tool (it can open
images) **before acting**; do not guess from the text alone. A message that is
only images arrives with the prompt `(see the attached image)`. The file stays
on disk while the session exists and a prompt or the transcript refers to it;
copy it elsewhere if you need it longer. An artifact cannot attach images
itself: `queuePrompt` has no image option, and annotations carry none.
## Layout issues
While the artifact is open, the browser passively audits it for **severe,
provable layout failures**: text cut off by its container, a control the user
cannot reach, text almost entirely covered by another element, content that
makes the page scroll sideways. Hidden elements, deliberate ellipsis or
line-clamp truncation, intentional scrollers, masked and screen-reader-only
content and anything mid-animation are ignored, and a finding must show up in
two samples a moment apart. What it finds goes to a **Layout issues** tray in
the top bar (with a count) and **nowhere else**:
- It **never wakes you** and **never appears in `inbox` or `poll`**. Do not go looking for
layout problems the user has not sent you, and never edit the artifact to
chase one on your own initiative.
- Only if the user opens the tray, selects issues and presses **Queue selected
fixes** (then Send to Agent) do they arrive, as an ordinary prompt:
```
- uid: pr_...
tag: layout-warnings
prompt: |
The browser flagged 2 layout problems in this artifact. Repair them:
1. [uwuiagklgk6avlfr] Text cut off by its container - On the right side, this text runs 550px beyond its container and is clipped. Selector: "div#bad-clip". Viewport: Desktop (1106px). Status: Open.
2. ...
Repair all of the issues above together, in one editing pass. Queueing this request does not claim a repair: ...
text: Layout issues: 2 selected
target: {"type":"layout-warnings","warnings":[{"id":"...","rule":"clipped-text","selector":"div#bad-clip","axis":"horizontal","overflow_px":549.9,"viewport_class":"desktop","viewport_width":1106}]}
```
Apply every listed fix in **one** edit, then `--reply` saying what changed. A
queued issue is a request, not a resolved issue: it is marked resolved only
after the browser reloads the edited artifact and a complete audit no longer
finds it, and it comes back as "Still present" if your fix did not work (the
user may queue it again). Issues are per viewport width class (mobile up to 640
px, compact up to 1024 px, desktop above): fixing the desktop layout does not
clear a phone-width issue. The audit only sees what the user's window shows, so
check dark and light and a narrow width yourself.
## Browser API inside the artifact: `window.forum`
The artifact runs sandboxed; `window.forum` (injected before your scripts) is
the whole API.
- `window.forum.queuePrompt(text, opts)` - puts one prompt in the user's
queue. Nothing reaches you until the user presses **Send to Agent**.
Returns a Promise of the queued prompt (rejects if the session ended).
Options:
- `tag` - short label you will see in `inbox` (or `poll`) output (default `feedback`).
- `text` - label or selection the prompt is about.
- `selector` - CSS selector of the element it is about.
- `target` - any JSON describing the target (a table cell, a row id).
- `data` - any JSON; appended to the prompt as `Context data:` followed by
pretty JSON.
- `queueKey` - a later **unsent** prompt with the same key replaces the
earlier one instead of piling up. It is derived automatically for radios,
checkboxes, inputs, selects and textareas (per form/fieldset), and from a
`data-forum-question="<id>"` attribute on a wrapper; pass `queueKey`
explicitly to override (`""` disables replacement).
- `element` - a DOM element to derive `selector`, `text` and `queueKey`
from (default: the focused element).
- `window.forum.onQueueChange(fn)` - calls `fn(keys)` with the `queueKey`s of
everything now waiting in the user's queue, once the page has them and on
every change (queued, removed, sent). Returns an unsubscribe function.
`window.forum.isQueued(key)` answers for one key.
Forms need no code for the common case: a `<form>` with
`data-forum-question="<id>"` (queue key `question:<id>`) or
`data-forum-queue-key="<key>"` has its submit buttons disabled while its
answer is queued and re-enabled once that message is removed from the queue
or sent; `data-forum-queued="true"` mirrors the state on the form. If you
pass a custom `queueKey` to `queuePrompt`, put the same value in
`data-forum-queue-key` (a `queueKey` equal to the bare question id also
works with `data-forum-question`; any other key is only matched to its form
while the page that submitted it stays open, not after a reload). Only buttons forum disabled are re-enabled; your
own `disabled` buttons are left alone.
Once the user has sent a form's answer, the form also gets
`data-forum-sent="sent"` (or `"answered"` once you answered that round) and
`data-forum-round="<n>"` (styling hooks; a form never sent has neither), and
`window.forum.sentStatus(key)` returns `{round, state}` or `null` for a key.
**Status badges.** forum itself draws a small badge on every such form,
whatever its markup, saying "Sent in round 2" or "Answered in round 2", and
on every element or text selection the user annotated and sent (found again
by the selector it was sent with, so it survives you rewriting the artifact;
if the selector no longer matches, the badge is skipped and the comment is
still in the conversation panel). "Answered" means you replied or the
artifact changed after that round. The badges are a fixed, pointer-transparent
overlay in a shadow root: they never move your layout, and you add nothing.
The user can hide them all with the **Marks** switch next to Annotate. Keep
stable selectors (ids, `data-forum-question`) on what the user is likely to
comment on, so the badges find their element after a rewrite.
- `window.forum.sendQueuedPrompts()` - delivers the whole queue immediately,
without the user having to click Send to Agent. Reserve it for a control
whose entire job is "submit this to the agent now".
Native controls (radios, checkboxes, inputs, selects, textareas, buttons,
labels, `<summary>`, links inside forms) **always act exactly as authored**, in
every mode: a plain click on them never annotates and is never intercepted, so
decision forms need no special treatment. Only Alt/Option+click annotates a
control (see "Annotations"). `window.forum` only exists when the page is opened
through `vx forum`; a copy opened from disk has no `window.forum`, so
guard calls or tell the user how to open it.
## Annotations
Every artifact can be annotated by the user with no work on your side:
- **Annotate** (a switch in the top bar, showing On or Off; Ctrl/Cmd+I toggles
it): it is **On by default** in every new page load, and the user's choice is
remembered. While On, hover outlines an element, a click selects it and opens
a note card; Enter queues the note, Esc cancels. A plain click annotates
**content** (text, headings, cards, images, links in prose). The artifact's
**controls always act normally** with a plain click: radios, checkboxes,
inputs, selects, textareas, buttons, labels (a click on a label activates its
control), `<summary>` and links inside a form, so a decision form works with
Annotate On and you need not warn the user. To annotate a control instead,
**hold Alt/Option while clicking it**: that annotates it without toggling,
typing or submitting. The hover outline skips controls unless Alt/Option is
held.
- **Selecting text** (in or out of annotation mode) offers an
**Annotate selection** action that opens the same card.
Each annotation joins the user's queue (removable there) and reaches you with
the next inbox, like any prompt. The note is `prompt`; what it is about is in
the other fields:
```
- uid: pr_...
tag: button # the element's tag, or `text` for a selection
prompt: Make this button larger # the user's note
selector: button[data-testid="save"] # unique CSS selector of the element
# (for a selection: the element that contains it)
text: Save changes # the element's text (<= 240 chars) or the selected text (<= 500)
```
A long selection also carries `target: {"type":"text-range","selector":...,"text":...}`
with up to 2000 characters of it. The selector prefers a unique `id`, then
`data-testid`/`data-forum-question`/`name`, then a `:nth-of-type` path; use it
to find the element in your source (`querySelector` semantics), and the
`text` to find it by content when the markup is generated. Stable
`id`/`data-testid` attributes in your artifact make annotations land exactly.
Reply with `--reply` as usual: say what you changed and where. An annotation
carries only the note and what it is about; images are attached to a composer
message (see "Images") and layout issues arrive only when the user queues them
(see "Layout issues").
## Decision forms
Build choices from native controls and call `window.forum.queuePrompt` **once,
on submit** - never from `change` or `click` handlers of radios, checkboxes or
selects, because the user may still change their mind. A submit button then
queues the final answer. While that answer is queued, forum disables the
form's submit button by itself (the form carries `data-forum-question`), and
enables it again if the user removes the message from the queue or sends it, so
a changed mind goes through the queue, not a second submit. A re-submission
under the same `queueKey` (only possible from code) replaces the queued answer
in place and it stays queued. Full patterns (single choice, tracked batch, free text) are in
`playbooks/input.md`.
```html
<form data-forum-question="region" onsubmit="event.preventDefault();
const picked = event.currentTarget.elements.region.value;
if (!picked) return;
window.forum.queuePrompt(`Deploy to ${picked}`, {
tag: 'decision',
text: `Region: ${picked}`,
element: event.currentTarget,
data: { question: 'region', answer: picked },
});">
<fieldset>
<legend>Where should this deploy first?</legend>
<label><input type="radio" name="region" value="eu-west"> eu-west</label>
<label><input type="radio" name="region" value="us-east"> us-east</label>
</fieldset>
<button type="submit">Queue my pick</button>
</form>
```
Show the user the difference between "selected" and "queued", and end every
decision path with an obvious way to send it (the panel's Send to Agent, or a
`sendQueuedPrompts` button).
## Whiteboards (Mermaid, opt-in)
Mermaid is **not** the diagram default (see `playbooks/diagram.md`). Write a
diagram as `<div class="mermaid">...</div>` solely when the user has requested
an editable whiteboard. The browser turns that block into an embedded
Excalidraw whiteboard (click it to unlock editing; Fullscreen enlarges it to
the whole page). Flowcharts, sequence, class, ER and state diagrams are
converted into shapes the user can edit, while any other type is embedded as a
picture the user can draw over. Edits are autosaved in the browser. **Queue
feedback** writes a `.excalidraw` scene and a PNG preview to
`~/.vexillum/forums/<key>/whiteboards/` and queues a prompt with
`tag: whiteboard`, which the inbox (or `poll`) delivers like any other:
```
- uid: pr_...
tag: whiteboard
prompt: |
Whiteboard feedback for diagram 1 of 2.
Edit summary:
- Added node "Cache"
Reviewer remark: also rename the queue to "Jobs"
Scene (.excalidraw JSON): /.../whiteboards/0.excalidraw
Preview (PNG): /.../whiteboards/0.png
Read the summary first ...
text: Whiteboard: diagram 1
target: {"whiteboard":1,"scenePath":"...","previewPath":"..."}
```
The `Reviewer remark:` line carries the optional note the reviewer typed next to
Queue feedback, and is present only when they typed one. Read the summary and
the remark first; the scene and preview files are for when those leave a
question open. The change itself always goes into the **Mermaid source in the
artifact**, because the scene file is output only and is never read back.
## Playbooks
Open every playbook that matches before writing HTML (one artifact often
combines several, e.g. a plan with a comparison and a diagram):
| Playbook | Use when |
|---|---|
| `playbooks/plan.md` | Lay out what will be built, and how, before any code is written. |
| `playbooks/comparison.md` | Weigh alternatives against each other, or today's behavior against the target. |
| `playbooks/input.md` | Collect decisions, choices, preferences or triage from inside the artifact. **Required** whenever you need structured answers. |
| `playbooks/diagram.md` | Draw how parts relate, how data flows, what states exist or how a system is put together. |
| `playbooks/table.md` | Lay many records out so a reviewer can scan and judge them quickly. |
## Design: use forum's own styles
forum gives every artifact its own identity (the vexillum design system), so
**do not pull Tailwind, daisyUI, Bootstrap or any CSS framework from a CDN**:
they bring a different look, need the network, and fight the theme. Write plain
semantic HTML and use the classes below. The stylesheet is injected for you
(`/forum-assets/forum-tokens.css` and `/forum-assets/forum-artifact.css`, linked
ahead of your markup), follows the user's dark/light switch (dark by default),
and needs no `<link>` from you. **It is applied only to an artifact that brings
no styling of its own** (see "When the forum styles apply" below).
- **Bare elements are already styled**: `h1`-`h6`, `p`, `a`, `ul`/`ol`,
`blockquote`, `code`, `pre`, `table`, `button`, `input`, `select`,
`textarea`, `fieldset`, `details`. Write them without classes first.
- **Components** (classes): `fr-card` (`fr-card--accent`), `fr-badge`
(`--accent` `--success` `--danger` `--bronze`), `fr-callout` (`--info`
`--success` `--danger` `--bronze`), `fr-btn` (`--primary` `--secondary`
`--bronze` `--danger`), `fr-stat` (`fr-stat-value`, `fr-stat-label`),
`fr-table-wrap` (wraps a `<table>` so wide tables scroll instead of
overflowing), `fr-figure` (an SVG with a `figcaption`).
- **Decision forms**: `fr-form` (a stack), `fr-field` (label above control),
`fr-choices` + `fr-choice` (a radio or checkbox as a selectable row; the
checked one highlights itself), `fr-actions` (button row). A
`<button type="submit">` is the primary action by default.
- **Layout helpers** (opt-in, never applied to the page by default):
`fr-page` (centered 72rem column with padding), `fr-stack` (vertical gap;
it is the only spacing between its children, so do not add margins to them,
and a heading directly inside it sits close to the block it introduces),
`fr-cluster` (wrapping row), `fr-grid` (responsive columns), `fr-muted`.
- **SVG diagrams**: `fr-node` (`--accent` `--success` `--danger` `--bronze`),
`fr-edge`, `fr-arrow`, `fr-label` (`--muted`) instead of hard-coded colors.
- **Tokens**: for anything custom, use the custom properties (`--fr-bg`,
`--fr-surface`, `--fr-surface-sunken`, `--fr-border`, `--fr-text`,
`--fr-text-secondary`, `--fr-accent`, `--fr-success`, `--fr-danger`,
`--fr-bronze`, `--fr-space-1..6`, `--fr-radius-sm|md|lg`, `--fr-font-sans`,
`--fr-font-mono`, ...), never raw hex, so the theme switch keeps working.
Do not use `--fr-selection` (tyrian): it belongs to annotations.
A page skeleton:
```html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Plan: rename the sync layer</title></head>
<body>
<main class="fr-page fr-stack">
<h1>Rename the sync layer <span class="fr-badge fr-badge--accent">proposal</span></h1>
<p class="fr-muted">What changes, why, and what I need from you.</p>
<div class="fr-callout fr-callout--info">One decision needed: see the form at the end.</div>
<section class="fr-grid">
<article class="fr-card"><h3>Before</h3><p>...</p></article>
<article class="fr-card fr-card--accent"><h3>After</h3><p>...</p></article>
</section>
</main>
</body>
</html>
```
### When the forum styles apply
The stylesheet is injected **only if the artifact has none of its own**. Any of
these makes the artifact "self-styled", and forum leaves its look alone:
- a `<style>` block, anywhere in the document;
- a `<link rel="stylesheet">` (a local file, Google Fonts CSS, anything);
- a CSS framework loaded from a CDN: a `<script src>` or stylesheet link whose
address names Tailwind, daisyUI, Bootstrap, Bulma, UnoCSS, Twind, Windi,
Materialize, Semantic UI, Pico, water.css or mvp.css. A CDN framework counts
as the artifact's own style (it sets the whole look), which is also why you
must not use one: you would opt out of the identity by accident.
Inline `style="..."` attributes do **not** count, so for a one-off tweak on a
forum-styled page (a width, a margin) write `style="..."` and keep the rest.
Adding a `<style>` block "just for a small tweak" turns the forum styles off
for the whole page: if you want both, say so explicitly.
Override the default with a meta in `<head>` (`none` wins if both appear):
| Meta | Effect |
|---|---|
| *(none)* | automatic: forum styles only when the artifact brings no style of its own |
| `<meta name="forum-style" content="on">` | always inject, even with your own `<style>`/stylesheet. Your CSS still wins on any conflict: the forum sheet sits in a low-priority cascade layer, so a small `<style>` of tweaks can sit on top of the forum look |
| `<meta name="forum-style" content="none">` | never inject: the page is entirely yours (a mock of the user's product in its own design system, a styled slide deck) |
`window.forum` is injected in every case. Whatever you choose: no external CDN
dependencies for styling, check dark and light theme and a narrow width, no
horizontal overflow. Say in your handoff whether the page uses the forum styles
or its own, and why.playbooks/comparison.md
# Comparison: options, tradeoffs, before and after
Use it when the user has to weigh alternatives, or needs to see how something
differs between now and later. The page should let them see the difference at
a glance and, when a choice is needed, make it without leaving the page.
## Pick the form
| Situation | Form |
|---|---|
| One thing changing over time | **Before / after**: two cards with matching headings |
| Mutually exclusive directions | **Option cards** in a grid, one per option |
| Many options against explicit criteria | A **scorecard** table (`fr-table-wrap`), only if the criteria are real and comparable |
| One option vs. the status quo | Before / after, with the status quo as "before" |
Do not use a scorecard to look rigorous. Invented numeric scores hide the
reasoning; a short sentence per cell is more honest.
## Build it in this order
1. **State the decision** in the title or the first line: "Where should the
cache live?", not "Cache options".
2. **Show each option concretely.** A real config snippet, a command, a
screenshot, a table of before and after values: whatever the option would
actually look like. "Simpler" and "more flexible" are conclusions, not
evidence.
3. **Keep the options parallel.** Same headings in the same order inside every
card, same units, same level of detail, so the eye can run across and see
exactly where they differ.
4. **Give cost the same weight as benefit.** Each option gets what it costs
(time, risk, lock-in, a new dependency) in the same visual treatment as what
it gives.
5. **State your assumptions.** If the verdict flips when an assumption does
("assuming under 1000 users"), write it next to the verdict.
6. **Recommend only what the evidence supports.** When one option is clearly
better, mark it (`fr-card--accent` plus a "recommended" badge) and say why in
one line. When it is a close call, say so and do not dress one option up.
## Letting the user decide
Put a form under the cards and queue the choice once, on submit; the full
mechanics (and why never on click) are in the input playbook. Native radios in
a single form, plus an optional reason:
```html
<form class="fr-form" data-forum-question="cache-layer">
<fieldset class="fr-choices" style="border:0;padding:0">
<legend style="padding:0">Pick a direction</legend>
<label class="fr-choice"><input type="radio" name="option" value="memory" required>
<span>In memory</span></label>
<label class="fr-choice"><input type="radio" name="option" value="disk">
<span>On disk</span></label>
</fieldset>
<div class="fr-field">
<label for="why">Reason (optional)</label>
<textarea id="why" name="why" rows="2"></textarea>
</div>
<div class="fr-actions"><button type="submit">Queue my choice</button></div>
</form>
<script>
document.querySelector('[data-forum-question="cache-layer"]')
.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const option = data.get('option');
if (!option) return;
const why = String(data.get('why') || '').trim();
window.forum.queuePrompt('Keep the cache ' + (option === 'disk' ? 'on disk' : 'in memory') +
(why ? '. Reason: ' + why : ''),
{ tag: 'choice', text: 'Cache: ' + option, element: form,
data: { question: 'cache-layer', answer: option, reason: why } });
});
</script>
```
Submitting again before sending replaces the earlier answer. After the user
commits, update the page: keep the chosen option, fold the rest into a short
"considered and set aside" note, and remove the form.
If the comparison is only for understanding (no decision), skip the form and
let the user comment on the page, which they can do by selecting text or
clicking an element.
## Example
```html
<main class="fr-page fr-stack">
<h1>Where should the cache live?</h1>
<p class="fr-muted">Assuming one process and fewer than 10k entries.</p>
<section class="fr-grid">
<article class="fr-card" id="opt-memory">
<h3>In memory</h3>
<p><code>cache := map[string]Entry{}</code> guarded by a mutex.</p>
<p><span class="fr-badge fr-badge--success">no extra I/O</span>
<span class="fr-badge fr-badge--danger">cold start after every restart</span></p>
</article>
<article class="fr-card fr-card--accent" id="opt-disk">
<h3>On disk <span class="fr-badge fr-badge--accent">recommended</span></h3>
<p>One JSON file per key, written atomically.</p>
<p><span class="fr-badge fr-badge--success">survives restarts</span>
<span class="fr-badge fr-badge--bronze">one read per lookup</span></p>
</article>
</section>
</main>
```
## Styling and layout checks
Use forum's classes (`fr-card`, `fr-grid`, `fr-badge`, `fr-table-wrap`), not a
CDN framework. They are defined in `forum-artifact.css`, which forum injects
only while the artifact has no `<style>`, no stylesheet `<link>` and no CSS
framework of its own. Tweak with `style="..."` attributes, or add
`<meta name="forum-style" content="on">` when you need your own `<style>` block
on top (SKILL.md, "When the forum styles apply").
The browser audits layout (clipped text, unreachable controls, text hidden
under another element, sideways scroll) and files findings in the user's
Layout issues tray without waking you. Prevent them: let text wrap, avoid fixed
widths on anything that clips, and check dark, light and a phone-width view
yourself.
## Mistakes to avoid
- Making all options look equally good when you prefer one.
- Comparing summaries when you could show the real thing side by side.
- Leaving out the assumption that would change the recommendation.
- Cards whose sections differ in order or wording, which forces the reader to
hunt for the matching part.playbooks/diagram.md
# Diagram: show how something works
Use it to explain relationships, flows, state machines, architecture and
concepts. The goal is that the reader understands the mechanism after looking
at the picture, and only needs the text for the reasons behind it.
## Pick the tool
- **Hand-written inline SVG is the default.** You control position, size,
emphasis and grouping, so the layout itself says something: the important
box is big, the dependency points the way it really goes, the boundary is a
visible region.
- **Mermaid is opt-in.** Reach for it solely when the user has requested a
whiteboard they can edit. Write `<div class="mermaid">...</div>`; the forum
browser turns it into a whiteboard the user can redraw, and what they change
returns as a `tag: whiteboard` prompt (see "Whiteboards" in SKILL.md). Do not
choose it merely because it is faster to write: it hands the layout over to
an engine.
- **Not div-and-flexbox boxes with arrows.** Figures are SVG; HTML carries the
prose around them.
## Compose the explanation
- **Start from the question.** Title each figure with what it answers ("How
does a prompt get from the page to the agent?"), not with the name of the
component.
- **Assume no prior knowledge.** Introduce the pieces before the connections
between them.
- **One idea per figure.** Several small figures that build on each other beat
a single dense one. For a large system: a small overview of the main parts,
then a card per part with its own detail below.
- **Overview first, evidence second.** Put the core relationship at the top and
push file paths, edge cases and caveats under it.
- **Separate topology from detail.** What is connected to what is one picture;
what flows through each connection is another.
- **Claims need support.** Do not draw an architecture you have not checked in
the code; cite the file or command next to the figure.
## Drawing it
- Size with `viewBox` and `width="100%"`, never fixed pixel dimensions, and
keep every shape and label inside the viewBox so nothing is cropped.
- Colors come from forum's classes (`fr-node`, `fr-edge`, `fr-arrow`,
`fr-label`, and their variants) or from `currentColor` and `--fr-*` custom
properties, never raw hex. That keeps the figure correct in dark and light.
- Every node, edge and region worth discussing needs its own permanent `id`
and a `<title>`; that is what lets the user comment on exactly one part and
lets screen readers navigate the figure. Add `role="img"` and an `aria-labelledby` title
to the `<svg>`.
- Labels stay to a few words. SVG text does not wrap, so brevity is also your
protection against overflow. Longer explanations go in HTML beside or under
the figure.
- Emphasize by size, weight and the accent variants (`fr-node--accent`,
`--success`, `--danger`, `--bronze`), and use `fr-label--muted` for
secondary text. One accent per figure usually reads better than five.
- Self-contained: no external images, fonts or scripts.
- If you use Mermaid, initialize it from the page theme: read
`data-fr-theme` on `<html>` (it follows the user's dark/light switch)
instead of hardcoding a theme.
## Example
```html
<figure class="fr-figure">
<svg viewBox="0 0 480 120" width="100%" role="img" aria-labelledby="flow-title">
<title id="flow-title">A prompt travels from the page to the agent</title>
<defs>
<marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
<path class="fr-arrow" d="M0 0L10 5L0 10z"/>
</marker>
</defs>
<g id="page"><title>The artifact page</title>
<rect class="fr-node" x="10" y="35" width="120" height="50" rx="6"/>
<text class="fr-label" x="70" y="65" text-anchor="middle">page</text></g>
<path id="queue-edge" class="fr-edge" d="M130 60H180" marker-end="url(#head)"><title>queuePrompt</title></path>
<g id="queue"><title>The user's queue</title>
<rect class="fr-node fr-node--accent" x="180" y="35" width="120" height="50" rx="6"/>
<text class="fr-label" x="240" y="65" text-anchor="middle">queue</text></g>
<path id="send-edge" class="fr-edge" d="M300 60H350" marker-end="url(#head)"><title>Send to Agent</title></path>
<g id="inbox"><title>The project inbox</title>
<rect class="fr-node" x="350" y="35" width="120" height="50" rx="6"/>
<text class="fr-label" x="410" y="65" text-anchor="middle">inbox</text></g>
<text class="fr-label fr-label--muted" x="240" y="108" text-anchor="middle">the user decides when to send</text>
</svg>
<figcaption>Nothing leaves the queue until the user presses Send to Agent.</figcaption>
</figure>
```
## With forum
- When part of the picture is uncertain, label it as a question and attach a
small decision form (input playbook) so the user resolves it in place.
- A whiteboard's edits arrive as a `tag: whiteboard` prompt with a bounded edit
summary plus the paths of a scene file and a PNG preview. The summary
usually says enough; open the scene or PNG only for a detail it lacks. Apply
the change to the **Mermaid source** in the artifact, since the scene file is
never read back.
- Keep stable ids on nodes: the user's annotation records the element's
selector, so a figure that keeps its ids keeps its comments attached.
## Styling and layout checks
Use forum's classes, not a CDN framework. The `fr-*` figure classes live in
`forum-artifact.css`, which forum injects only while the artifact has no
`<style>`, no stylesheet `<link>` and no CSS framework of its own. For a
tweak use `style="..."`; if a `<style>` block is unavoidable, add
`<meta name="forum-style" content="on">` so the forum look stays underneath
(SKILL.md, "When the forum styles apply").
The browser audits layout (clipped text, unreachable controls, covered text,
sideways scroll) and files findings in the user's Layout issues tray; they
reach you only if the user queues them. Before handing over, view the figure in
dark, in light and at phone width, and confirm no label is cut off.
## Mistakes to avoid
- One figure containing every file and function.
- Boxes-and-arrows built out of HTML elements.
- Using Mermaid to save effort when nobody asked for a whiteboard.
- Hard-coded colors that vanish in one of the themes.
- Architecture claims you did not verify.playbooks/input.md
# Input: decisions, answers and triage
Read this before writing any artifact that asks the user something. It covers
how to turn a question into controls, how an answer travels back to you, and
how to account for it afterwards.
## Pick the mechanism
| The user has to... | Build |
|---|---|
| pick one option, or a few | A form with radios or checkboxes, one submit |
| rule on a list of items (accept, defer, drop) | A **tracked batch**: one row per item, one submit |
| explain something in their own words | Nothing: the conversation panel is already a free-text channel. Add a textarea only when it belongs to a specific choice |
| read something | No control at all. Never make reading depend on clicking |
If a question can be answered faster by clicking than by typing, put the
controls in the page. If the user would just type a sentence anyway, ask the
question in prose and let them reply in the panel.
## The rule that matters most: queue once, on submit
`window.forum.queuePrompt(text, opts)` puts a message in the user's queue. The
user can still change their mind while they pick, so a prompt is created only
when they commit:
- Radios, checkboxes and selects only change what is selected on the page.
- A form `submit` (or an explicit "Queue answer" button) reads the final
values and calls `queuePrompt` **exactly once**.
- Never call `queuePrompt` from `change` or `click`; you would flood the
queue with answers the user has already abandoned.
Native controls (inputs, selects, textareas, buttons, labels, `<summary>`,
`contenteditable`) work as authored with the **Annotate** switch On, which is
the default: a plain click acts on the control. Only Alt/Option+click turns a
click into a comment on it, which is how a user can question one specific
option. You never need to tell them to turn annotation off.
## What forum does for a decision form
Mark the form with `data-forum-question="<id>"` and forum takes care of the
life cycle, with no code from you:
1. The prompt's `queueKey` becomes `question:<id>`, so a second submission
before sending **replaces** the first instead of stacking.
2. While that answer is queued the form's submit buttons are disabled
(`data-forum-queued="true"` is set on the form). They come back when the
user removes the message from the queue or sends it. Buttons you disabled
yourself are left alone.
3. After the user sends it, the form gets `data-forum-sent="sent"` and
`data-forum-round="<n>"`; once you answer that round it turns to
`"answered"`. forum also paints a "Sent in round N" / "Answered in round N"
badge on the form by itself. Do not draw your own.
Things you should still do:
- Say what the disabled button means ("Queued: remove it from the queue to
change it") and style it off `form[data-forum-queued]`. Keep "selected" and
"queued" visibly different, so the user knows what will be sent.
- With a custom key, pass the same string as `queueKey` and as
`data-forum-queue-key` on the form. Any key that is not the bare question id
is only matched back to its form while the page that queued it stays open.
- For bespoke UI, `window.forum.onQueueChange((keys) => ...)` reports the keys
waiting in the queue, `window.forum.isQueued(key)` checks one, and
`window.forum.sentStatus(key)` returns `{ round, state }` or `null`.
- Give every prompt enough context to act on without asking back: pass `tag`
(what kind of answer), `text` (a human label), `selector` or `element` (what
it is about), `target` (any JSON locator) and `data` (any JSON; it reaches
you printed after `Context data:`).
## Pattern 1: a single choice with a reason
This is the whole thing, already styled with forum's classes. The form is a
stack, the fieldset is a column of selectable rows (the checked one lights
up), and a submit button is the primary action.
```html
<form class="fr-form" data-forum-question="cache-layer">
<fieldset class="fr-choices" style="border:0;padding:0">
<legend style="padding:0">Where should the cache live?</legend>
<label class="fr-choice">
<input type="radio" name="cache" value="memory" required>
<span>In memory <small class="fr-muted">fastest, lost on restart</small></span>
</label>
<label class="fr-choice">
<input type="radio" name="cache" value="disk">
<span>On disk <small class="fr-muted">survives restarts, one extra read</small></span>
</label>
</fieldset>
<div class="fr-field">
<label for="cache-why">Anything I should know? (optional)</label>
<textarea id="cache-why" name="why" rows="2"></textarea>
</div>
<div class="fr-actions"><button type="submit">Queue this answer</button></div>
</form>
<script>
document.querySelector('[data-forum-question="cache-layer"]')
.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const choice = data.get('cache');
if (!choice) return;
const why = String(data.get('why') || '').trim();
window.forum.queuePrompt(
'Put the cache ' + (choice === 'disk' ? 'on disk' : 'in memory') +
(why ? '. Reason: ' + why : ''),
{ tag: 'choice', text: 'Cache: ' + choice, element: form,
data: { question: 'cache-layer', answer: choice, reason: why } });
});
</script>
```
Inline `onsubmit="..."` on the form works the same; use whichever reads better.
Check the choice before you queue, so an empty submission queues nothing.
## Pattern 2: a tracked batch
Use it when completeness matters: every finding must end up fixed, deferred or
rejected, and nothing may be silently dropped. The user marks a disposition per
item and submits **one** prompt carrying the full set.
```html
<form class="fr-form" data-forum-question="review-triage">
<div class="fr-table-wrap">
<table>
<thead><tr><th>ID</th><th>Finding</th><th>Do</th></tr></thead>
<tbody>
<tr data-item="F-1" data-label="Unbounded retry loop">
<td style="white-space:nowrap">F-1</td><td>Unbounded retry loop</td>
<td><select name="F-1" aria-label="Disposition for F-1">
<option value="">Leave out</option><option value="fix">Fix</option>
<option value="defer">Defer</option><option value="reject">Reject</option>
</select></td>
</tr>
<tr data-item="F-2" data-label="Stale help text">
<td style="white-space:nowrap">F-2</td><td>Stale help text</td>
<td><select name="F-2" aria-label="Disposition for F-2">
<option value="">Leave out</option><option value="fix">Fix</option>
<option value="defer">Defer</option><option value="reject">Reject</option>
</select></td>
</tr>
</tbody>
</table>
</div>
<div class="fr-actions"><button type="submit">Queue triage</button></div>
</form>
<script>
document.querySelector('[data-forum-question="review-triage"]')
.addEventListener('submit', (event) => {
event.preventDefault();
const form = event.currentTarget;
const items = [...form.querySelectorAll('tr[data-item]')]
.map((row) => ({ id: row.dataset.item, label: row.dataset.label,
disposition: row.querySelector('select').value }))
.filter((item) => item.disposition);
if (!items.length) return;
window.forum.queuePrompt(
'Apply this triage. Return a receipt that gives every submitted ID ' +
'exactly one outcome before you call the work done.',
{ tag: 'tracked-batch', text: items.length + ' triaged items',
element: form, data: { items } });
});
</script>
```
Rules for a batch:
- Every item has a short, stable, **visible** ID (`F-1`), so the user, the
prompt and your receipt all talk about the same thing.
- `data.items` stays small and bounded: id, a short label, the disposition.
Put long evidence on the page, not in the prompt.
- Your receipt (sent with `vx forum reply <file> --reply-file -`, or the
`poll` forms if you are polling by hand) settles each submitted ID in
exactly one way: **addressed**, backed by concrete evidence (a file, a commit,
a command); **deferred**, with the reason it waits; or **rejected**, with the
reason it was turned down. Before you say
"done", compare the set of submitted IDs with the set in the receipt and
report any ID that is missing.
## Sending
Queued messages leave when the user presses **Send to Agent** in the panel, so
close every decision path with a plain sentence telling them so. Call
`window.forum.sendQueuedPrompts()` only from a control whose entire meaning is
"submit this to the agent now" (a "Send my answers" button at the end of a
long form); it flushes the whole queue, not just your form.
## Images
The artifact cannot take images: `queuePrompt` has no attachment option, and
an upload control would go nowhere. When "show me" is the real answer, ask for
it in text ("paste a screenshot of the broken state into the conversation").
The user attaches it to a panel message and it reaches you as a local path in
`attachments` (see `vx forum inbox`). Open the file before acting.
## Custom controls
If a native control cannot do the job (a star rating, a drag-to-rank list),
let its elements update local state only, and keep a separate "Queue answer"
button that reads that state and queues once. Use `queuePrompt` for what the
user means, never for analytics or to mirror hover and focus state.
## Styling
Use forum's classes (`fr-form`, `fr-field`, `fr-choices`, `fr-choice`,
`fr-actions`, `fr-table-wrap`), never a CDN framework. They come from
`forum-artifact.css`, which forum injects only while the artifact has no
`<style>`, no stylesheet `<link>` and no CSS framework of its own. Small tweaks
go in `style="..."` attributes. If you really need a `<style>` block, add
`<meta name="forum-style" content="on">` so the forum look stays underneath it
(see "When the forum styles apply" in SKILL.md).
The browser also audits the page for severe layout faults and keeps them in
the user's Layout issues tray; it never wakes you with them. Prevent them:
let text wrap, keep fixed widths off anything that clips, and look at the form
at a narrow width and in both themes before you hand it over.
## Mistakes to avoid
- A prompt per radio change, checkbox toggle or select change.
- Vague prompts ("Option B") that you cannot execute without asking again.
- Hiding the difference between "selected here" and "queued for the agent".
- Questions that were already answered, left in the page. After a decision,
update the artifact to show the outcome and remove the question.
- Requiring a click for content that only needs to be read.playbooks/plan.md
# Plan: a proposal the user can inspect before anything is built
Use it when the user should look at an approach before implementation starts,
or asked for a design doc, a PRD, an implementation plan or a proposal. If the
whole plan is one small choice, skip this and use the comparison or diagram
playbook on its own.
A plan page has one job: let someone who was not in the conversation see what
you propose, check that it is grounded, and tell you where you are wrong. It
is not a status report and it is not a brainstorm.
## Sections, in reading order
1. **Goal.** What this achieves and for whom, in a sentence or two. Name what
is explicitly out of scope.
2. **Today.** How the relevant part of the system behaves now, with the files,
commands or output that show it.
3. **Target.** How it should behave afterwards. Where a user-visible surface
changes, show it (see below) instead of describing it.
4. **Approach.** The decisions that shape the work, at a high level: which
modules change, in what order, what is reused, what is new. Give the reason
for every decision that was not obvious.
5. **Risks and failure modes.** What can go wrong, how you would notice, and
how it is undone. Include migration concerns and backwards compatibility,
even when the answer is "none, because ...".
6. **Open questions.** Only the ones you cannot settle yourself, each with the
options and your recommendation, ideally as a decision form (input
playbook) so the user answers in one click.
Lead the proposal, not the uncertainty: the approach must be complete enough
that the open questions read as refinements, not as holes.
## Make it checkable
- **Verify before you assert.** Read the code or run the command before you
state something as fact, and cite it (`internal/forum/hub.go`, the output of
`go test ./...`). Mark what you inferred and did not check as an assumption.
- **Self-contained.** Another developer should be able to implement it from
the page alone, without the chat history. Spell out names, paths and
interfaces.
- **Show, don't narrate, for UI work.** When the plan changes what a person
sees, render a mock of it in the product's own design system, not forum's.
That page needs `<meta name="forum-style" content="none">` (or a section
that carries its own styles) and its own CSS; read the product's tokens or
component library first so the mock is faithful.
- **Reach for other playbooks inside the plan.** A flow or architecture
becomes an inline SVG (diagram playbook); competing approaches become
option cards (comparison playbook); a long inventory becomes a table.
## Working the review loop
- The user reads, annotates elements or text, writes in the panel and answers
your decision forms; their feedback comes back when they press **Send to
Agent**. A **Send & End** is their go-ahead to start building.
- After each round, edit the file (the browser reloads it) and then
`vx forum reply <file> --reply "<what changed>"`, so the round is marked
answered.
- **Keep the page current.** When a question is decided, rewrite the plan to
reflect the answer and delete the question. A plan that still lists
resolved questions makes the reader re-litigate them. Keep a short "Decided"
list if the history helps.
- Keep stable `id`s on sections and on anything people are likely to
comment on, so their notes still land on the right element after you
rewrite the page.
## Example skeleton
```html
<main class="fr-page fr-stack">
<h1>Plan: bound the prompt queue <span class="fr-badge fr-badge--accent">proposal</span></h1>
<div class="fr-callout fr-callout--info" id="goal">
<strong>Goal.</strong> A queued prompt is never lost and the queue never
grows without limit. Out of scope: changing how prompts are delivered.
</div>
<section class="fr-stack" id="today">
<h2>Today</h2>
<p>The queue is an unbounded slice persisted on every change
(<code>internal/forum/hub.go</code>).</p>
</section>
<section class="fr-grid" id="approach">
<article class="fr-card"><h3>1. Cap</h3><p>Reject the 201st prompt with a visible error.</p></article>
<article class="fr-card fr-card--accent"><h3>2. Persist</h3><p>Write to a temp file, then rename.</p></article>
</section>
<section class="fr-stack" id="risks">
<h2>Risks</h2>
<div class="fr-callout fr-callout--danger">A full queue must tell the user; a silent drop loses feedback.</div>
</section>
<div class="fr-callout fr-callout--bronze" id="q-cap">
<strong>Open question.</strong> Cap per session or global?
</div>
</main>
```
Put the decision form for an open question (input playbook) right under its
callout, so the question and the way to answer it sit together.
## Styling and layout checks
Unless the plan mocks a product UI, use forum's own classes (`fr-page`,
`fr-stack`, `fr-callout`, `fr-card`, `fr-grid`, `fr-badge`), never a CDN
framework. They come from `forum-artifact.css`, injected only while the
artifact has no `<style>`, no stylesheet `<link>` and no CSS framework of its
own. Tweak with `style="..."`; to add a `<style>` block and keep the forum look
beneath it, put `<meta name="forum-style" content="on">` in the head (SKILL.md,
"When the forum styles apply").
The browser quietly audits layout (clipped text, controls out of reach, text
covered by another element, sideways scroll) and lists problems in the user's
Layout issues tray; you hear about them only if the user queues the fixes.
Prevent them: let text wrap, keep fixed widths off clipping boxes, and view the
page in dark, light and at phone width before handing it over.
## Mistakes to avoid
- Questions that were answered and are still on the page.
- Only the contested choices, with the actual proposal missing.
- No failure modes, no migration or compatibility story.
- Claims about the code that you did not read.
- A plan that needs the chat to make sense.playbooks/table.md
# Table: dense records the user can scan and act on
Use it when the content is many records of the same kind (findings, files,
tasks, test results, options with criteria) and the user's job is to scan them,
spot what matters and maybe act on some rows.
## Table or something else
- **Table**: every row has the same fields and the reader compares across rows.
- **Cards** (`fr-card` in an `fr-grid`): records differ in shape, or each needs
a paragraph of explanation. A table with a "Notes" column that holds
paragraphs wants to be cards.
- **Stats above the table** (`fr-stat`): when a count or a ratio changes how
the rows should be read ("3 blockers out of 41").
## Anatomy
1. **A verdict line first.** One or two sentences saying what the rows add up
to and what, if anything, the user must do. A reader who stops here should
still know the answer.
2. **Optional stat strip.** A few `fr-stat` tiles with the counts that matter.
3. **The table.** Columns follow the questions a reviewer asks, left to right:
which record is it, what is the evidence, how bad or how done is it, what
happens next. Put the status near the left, not in the last column of a
wide table that scrolls out of view.
4. **Detail on demand.** Long output, stack traces and full diffs go in a
`<details>` under the row or on a card, so the grid stays one line per row.
## Making the grid readable
- Use real `<table>`, `<thead>`, `<th scope>` markup. Wrap it in
`fr-table-wrap` so a wide table scrolls inside its box rather than pushing
the page sideways.
- Status is a word inside a badge (`blocker`, `ok`, `needs info`), never only
a color. Reserve the loud variants (`fr-badge--danger`) for what really needs
attention, so they still mean something.
- Long paths, symbols and URLs need a plan for narrow screens: allow them to
wrap (`overflow-wrap: anywhere` in a `style` attribute) or let the wrapper
scroll. Do not clip them.
- Sort the rows by the user's priority (worst first), not by whatever order
your tool printed them in. Say how they are sorted.
- Keep short identifiers on one line (`white-space: nowrap` on the cell), or a
narrow screen will break `F-1` at its hyphen.
- Give each row you may later refer to a stable `id` (`id="row-12"`), so the
user's annotations and your own prompts point at the same element.
## Acting on rows with forum
A row that implies a change can carry its own button (see the example below
for the markup). Queue one specific prompt, and describe the row precisely so
you need nothing else to act:
```html
<script>
document.querySelectorAll('button[data-row]').forEach((button) => {
button.addEventListener('click', () => {
const row = button.closest('tr');
window.forum.queuePrompt(
'Handle the unbounded retry in ' + row.dataset.path,
{ tag: 'row-action', text: row.dataset.path, selector: '#' + row.id,
target: { row: Number(button.dataset.row), path: row.dataset.path } });
});
});
</script>
```
An action button is a deliberate click on an unambiguous "queue this" control,
so queueing on click is fine here. That is different from the rule in the
input playbook: never queue from a control the user is still adjusting.
If one decision covers several rows and none may be forgotten, use the
**tracked batch** from the input playbook: a checkbox or select per row, a
single submit, and a receipt from you that accounts for every submitted ID.
## Example
```html
<main class="fr-page fr-stack">
<h1>Retry audit <span class="fr-badge fr-badge--accent">12 findings</span></h1>
<p>Two retry loops can run forever; the rest are cosmetic. Fix F-1 and F-2
before release.</p>
<div class="fr-table-wrap">
<table>
<thead>
<tr><th scope="col">ID</th><th scope="col">Status</th>
<th scope="col">Where</th><th scope="col">Finding</th><th scope="col">Action</th></tr>
</thead>
<tbody>
<tr id="f-1" data-path="internal/sync/push.go">
<th scope="row" style="white-space:nowrap">F-1</th>
<td><span class="fr-badge fr-badge--danger">blocker</span></td>
<td><code>internal/sync/push.go</code></td>
<td>Retry loop has no upper bound</td>
<td><button type="button" class="fr-btn" data-row="1">Queue fix</button></td>
</tr>
<tr id="f-7" data-path="cmd/vx/help.go">
<th scope="row" style="white-space:nowrap">F-7</th>
<td><span class="fr-badge fr-badge--bronze">minor</span></td>
<td><code>cmd/vx/help.go</code></td>
<td>Help text mentions a removed flag</td>
<td><button type="button" class="fr-btn" data-row="7">Queue fix</button></td>
</tr>
</tbody>
</table>
</div>
</main>
```
## Styling and layout checks
Use forum's own classes (`fr-table-wrap`, `fr-badge`, `fr-stat`, `fr-card`,
`fr-grid`), not a CDN framework. They come from `forum-artifact.css`, which is
injected only while the artifact brings no `<style>`, stylesheet `<link>` or
CSS-framework CDN. For one-off tweaks use `style="..."`; if you truly need a
`<style>` block, add `<meta name="forum-style" content="on">` to keep the forum
look under it (SKILL.md, "When the forum styles apply").
The browser audits layout in the background (clipped text, controls pushed
off-screen, text under another element, sideways scrolling) and lists what it
finds in the user's Layout issues tray. It never wakes you; you hear about it
only if the user queues the fixes. So prevent it yourself: look at the table in
dark and light, and at phone width, before you hand it over.
## Mistakes to avoid
- Pasting terminal output into a `<pre>` and calling it a table.
- A conclusion buried under a screen of undifferentiated rows.
- Status encoded only in color, or in a column nobody sees on a small screen.
- Fixed-width cells that clip a path instead of letting it wrap.