diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 0000000..bbcc2f7 --- /dev/null +++ b/.claude/commands/ship.md @@ -0,0 +1,13 @@ +--- +description: Open a pull request for the current branch, drive it green, and merge it +argument-hint: [what the change is, if the branch name does not say] +--- + +Ship the work on the current branch by following **Shipping a change when +asked** in `CLAUDE.md`. That section owns the procedure; this command only +invokes it. + +$ARGUMENTS + +Before you start, report in one line: the branch, whether anything under +`plugins/` changed, and — if it did — the version you are shipping and why. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d485f7f..e7b89f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,3 +19,6 @@ drove it on. - [ ] Edited `lib/shell/` rather than a stamped region, ran `node plugins/vstack/lib/build-shell.mjs stamp`, and committed both. - [ ] Added or renamed a plugin, and updated `.claude-plugin/marketplace.json` in the same commit. - [ ] Renamed a tool, and added its former directory name to the `LEGACY` map in `lib/workdir.mjs`. +- [ ] Changed something under `plugins/`, and raised `version` in both host manifests with a matching `CHANGELOG.md` entry. Merging this publishes it. +- [ ] Every security scan passes, and no finding was silenced instead of fixed. +- [ ] Added a step that uses an action, and pinned it by commit SHA with the version in a trailing comment. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..56a9f92 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +# Actions are pinned by commit SHA, which is immutable and therefore never +# picks up an upstream fix on its own. Dependabot moves the pin and rewrites +# the version comment beside it. +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + # A compromised release is usually pulled within days of publication, so + # wait before moving a pin onto it. + cooldown: + default-days: 7 + commit-message: + prefix: ci diff --git a/.github/scripts/check-version.mjs b/.github/scripts/check-version.mjs new file mode 100644 index 0000000..b5bee4b --- /dev/null +++ b/.github/scripts/check-version.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/* + * check-version.mjs — "does this pull request ship what it changed?" + * + * A host decides an update exists by comparing the `version` in plugin.json on + * main against the version it installed. Everything under plugins/ reaches a + * user the moment it lands on main, so a change there that leaves the version + * alone ships to nobody: the code is live, and every installed copy still + * believes it is current. + * + * Nothing downstream can catch that. The release is already out by then, and + * the repair is another release. So it is caught here, on the pull request, + * while there is still one commit to add. + * + * Run on a pull request with BASE_SHA set to the base of the branch. + */ +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" + +const MANIFEST = "plugins/vstack/.claude-plugin/plugin.json" +const CHANGELOG = "CHANGELOG.md" +const SHIPPED_TO_USERS = "plugins/" +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/ + +const git = (...args) => execFileSync("git", args, { encoding: "utf8" }) + +const fail = (...lines) => { + for (const line of lines) console.error(line) + process.exit(1) +} + +const parse = (version, where) => { + const match = SEMVER.exec(version ?? "") + if (!match) fail(`${where} declares ${JSON.stringify(version)}, which is not a MAJOR.MINOR.PATCH version.`) + return match.slice(1, 4).map(Number) +} + +const isHigher = (candidate, current) => { + for (let part = 0; part < 3; part++) { + if (candidate[part] !== current[part]) return candidate[part] > current[part] + } + return false +} + +const base = process.env.BASE_SHA +if (!base) fail("BASE_SHA is not set, so there is nothing to compare this branch against.") + +const changed = git("diff", "--name-only", `${base}...HEAD`).split("\n").filter(Boolean) +const shipped = changed.filter(file => file.startsWith(SHIPPED_TO_USERS)) + +if (shipped.length === 0) { + console.log(`Nothing under ${SHIPPED_TO_USERS} changed, so this ships nothing and needs no version.`) + process.exit(0) +} + +const declared = JSON.parse(readFileSync(MANIFEST, "utf8")).version +// A branch that adds the manifest has nothing to be higher than. +let previous = null +try { + previous = JSON.parse(git("show", `${base}:${MANIFEST}`)).version +} catch { + console.log(`${MANIFEST} does not exist at the base of this branch.`) +} + +if (previous !== null && !isHigher(parse(declared, MANIFEST), parse(previous, `${MANIFEST} at the base`))) { + fail( + `${shipped.length} file(s) under ${SHIPPED_TO_USERS} changed, and every one of them reaches a user`, + `as soon as this merges. This branch declares ${declared} against ${previous} on the base, so no`, + "host will offer the update and the change ships to nobody.", + "", + "Raise `version` in BOTH host manifests and add the matching CHANGELOG.md entry:", + " plugins/vstack/.claude-plugin/plugin.json", + " plugins/vstack/.codex-plugin/plugin.json", + "", + "MAJOR for a breaking change to a skill name, an on-disk path, or a protocol.", + "MINOR for new behaviour. PATCH for a fix.", + "", + "Changed here:", + ...shipped.map(file => ` ${file}`), + ) +} + +const heading = new RegExp(`^## ${declared.replace(/\./g, "\\.")}\\b`, "m") +if (!heading.test(readFileSync(CHANGELOG, "utf8"))) { + fail( + `${CHANGELOG} has no entry for ${declared}, and that entry is published as the release notes.`, + "", + `Add a section starting "## ${declared}" above the previous release.`, + ) +} + +console.log(`Ships ${declared}, and ${CHANGELOG} says what is in it.`) diff --git a/.github/scripts/publish-release.mjs b/.github/scripts/publish-release.mjs new file mode 100644 index 0000000..bfe28f8 --- /dev/null +++ b/.github/scripts/publish-release.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/* + * publish-release.mjs — tag main at the version it declares, and publish it. + * + * The plugin is distributed by the repository itself, so merging to main is + * what ships it. The tag and the GitHub release are the record of what shipped, + * written from what is already in the tree: the version in plugin.json and its + * CHANGELOG.md entry. + * + * Keyed on the tag rather than the diff, so it is safe to re-run and does not + * care how the commit reached main. A commit whose version is already tagged + * publishes nothing. + * + * Run on a push to main with GH_TOKEN set. + */ +import { execFileSync } from "node:child_process" +import { readFileSync } from "node:fs" + +const MANIFEST = "plugins/vstack/.claude-plugin/plugin.json" +const CHANGELOG = "CHANGELOG.md" + +const gh = (...args) => execFileSync("gh", args, { encoding: "utf8" }) + +const version = JSON.parse(readFileSync(MANIFEST, "utf8")).version +const tag = `v${version}` + +try { + gh("release", "view", tag, "--json", "tagName") + console.log(`${tag} is already published. Nothing to do.`) + process.exit(0) +} catch { + // 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) + +if (start === -1) { + 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, + "--title", tag, + "--notes", notes, +) + +console.log(`Published ${tag}.`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0e0e35..b2eecc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,20 +6,21 @@ on: pull_request: branches: [main] -permissions: - contents: read - jobs: tests: name: Tests (Node ${{ matrix.node }}) runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false matrix: node: ['18', '22'] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} - name: Review lifecycle @@ -28,13 +29,67 @@ jobs: run: node plugins/vstack/skills/review/tests/host-profiles.mjs - name: Working-directory resolution run: node plugins/vstack/skills/review/tests/workdir.mjs + - name: Round gate + run: node plugins/vstack/skills/review/tests/round-gate.mjs + - name: Update check + run: node plugins/vstack/skills/review/tests/update-check.mjs + - name: Design tokens + run: node plugins/vstack/skills/review/tests/design-tokens.mjs + + e2e: + name: E2E (${{ matrix.host }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + host: [claude, codex] + defaults: + run: + working-directory: e2e + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: e2e/package-lock.json + - name: Install the suite + run: npm ci + # The @browser scenarios drive the workspace in a real Chromium. + - name: Install Chromium + run: npx playwright install --with-deps chromium + # The Gherkin features in e2e/features/ drive the real review server and + # CLI; the host decides which profile the workspace is stamped with. + - name: Review loop end to end + env: + VSTACK_HOST: ${{ matrix.host }} + run: npx cucumber-js --format progress --format summary:cucumber-summary.txt + - name: Publish the result to the run summary + if: always() + env: + HOST: ${{ matrix.host }} + run: | + { + echo "### E2E ($HOST)" + echo '```' + cat cucumber-summary.txt 2>/dev/null || echo 'The suite did not produce a summary.' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" shell: name: Stamped shell is current runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' # Fails when a page's stamped region has drifted from lib/shell/. @@ -45,19 +100,42 @@ jobs: manifests: name: Manifests runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' + # Unpinned on purpose: this job has to run the validator a marketplace + # reviewer would run today, and there is no lockfile to pin it against. + # --ignore-scripts keeps every transitive dependency's lifecycle script + # from running; the CLI does not work without its own postinstall, so + # that one is run explicitly and is the only script that executes. - name: Install Claude Code - run: npm install --global @anthropic-ai/claude-code + run: | # zizmor: ignore[adhoc-packages] + npm install --global --ignore-scripts @anthropic-ai/claude-code + node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs" # The community-marketplace review pipeline runs this same check on every # submission, so a warning here is a warning a reviewer would see. - name: Validate the marketplace run: claude plugin validate . --strict - name: Validate the plugin run: claude plugin validate ./plugins/vstack --strict + # The path a user takes. CLAUDE_CONFIG_DIR points it at a throwaway + # directory so the local marketplace entry is never written to a real one, + # where it would shadow the published cavalry-collective. The source must + # be ./ and not . + - name: Rehearse the install + run: | + SANDBOX=$(mktemp -d) + export CLAUDE_CONFIG_DIR="$SANDBOX/.claude" + claude plugin marketplace add ./ + claude plugin install vstack@cavalry-collective + claude plugin details vstack + rm -rf "$SANDBOX" # Both hosts read a version out of their own manifest, so they can drift # apart silently and ship the same commit under two version numbers. - name: Host manifests declare the same version @@ -73,3 +151,23 @@ jobs: } console.log(`Both host manifests declare ${claude}.`) ' + + version: + name: Plugin changes ship a version + # Only a pull request has a base to compare against. A push to main has + # already been through this. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # The check reads the manifest at the base of the branch, which a shallow + # clone does not have. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Compare against the base of the branch + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: node .github/scripts/check-version.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59b6607..2a8a232 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,32 +2,25 @@ name: Release on: push: - tags: ['v*'] - -permissions: - contents: read + branches: [main] jobs: - version-matches-tag: - name: Tag matches the manifest version + publish: + name: Tag and publish the version main declares runs-on: ubuntu-latest + # Creates a tag and a release. The `main` ruleset guards the branch, not + # tags, so nothing here needs to bypass it. + permissions: + contents: write steps: - - uses: actions/checkout@v4 - # An explicit version in plugin.json is what Claude Code compares against - # to decide an update exists. Tagging a release without bumping it leaves - # every installed copy believing it is already current. - - name: Compare the tag against plugin.json - run: | - node -e ' - const { readFileSync } = require("node:fs") - const tag = process.env.GITHUB_REF_NAME.replace(/^v/, "") - const declared = JSON.parse( - readFileSync("plugins/vstack/.claude-plugin/plugin.json", "utf8") - ).version - if (declared !== tag) { - console.error(`Tag ${process.env.GITHUB_REF_NAME} does not match plugin.json version ${declared}.`) - console.error("Bump the version in both host manifests, commit, then move the tag.") - process.exit(1) - } - console.log(`Tag and manifest agree on ${declared}.`) - ' + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Publishes the version already in the tree, with its CHANGELOG.md entry + # as the notes. Does nothing when that version is already tagged, which is + # every merge that did not change the plugin. + - name: Publish the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_SHA: ${{ github.sha }} + run: node .github/scripts/publish-release.mjs diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..be84a4f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,36 @@ +name: Scorecard + +# OpenSSF Scorecard rates the repository's supply-chain posture: branch +# protection, pinned actions, token permissions, release signing. It reads the +# repository rather than the code, so it runs on main and on a schedule, not on +# a pull request. +on: + branch_protection_rule: + push: + branches: [main] + schedule: + - cron: '41 5 * * 1' + +jobs: + analysis: + name: OpenSSF Scorecard + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + # Publishing the result is what lets the README badge read a score. + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: Upload the result + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..6753c81 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,102 @@ +name: Security + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # A weekly run finds a rule or advisory published after the last commit. + - cron: '23 5 * * 1' + +jobs: + sast: + name: SAST (CodeQL) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Initialise CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + # HTML is scanned too: every page carries inline script. + languages: javascript-typescript + queries: security-and-quality + - name: Analyse + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + + secrets: + name: Secret scan (Gitleaks) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Anything ever committed is compromised, so the scan reads the whole + # history. A shallow clone shows only the tip. + fetch-depth: 0 + persist-credentials: false + - name: Install Gitleaks + env: + # The GitHub Action wrapper needs a paid licence for organisation + # repositories. The CLI it wraps is MIT, so this installs that. + GITLEAKS_VERSION: 8.30.1 + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + release="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" + curl --proto '=https' --tlsv1.2 -fsSL -O "${release}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl --proto '=https' --tlsv1.2 -fsSL -O "${release}/gitleaks_${GITLEAKS_VERSION}_checksums.txt" + grep "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + "gitleaks_${GITLEAKS_VERSION}_checksums.txt" | sha256sum -c - + tar -xzf "gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" gitleaks + - name: Scan every commit + run: | + "$RUNNER_TEMP/gitleaks" git --no-banner --redact --verbose + + workflows: + name: Workflow audit (zizmor) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The workflows are the only thing here that runs with repository + # credentials, so they get their own static analysis. + - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 + with: + version: 1.29.0 + + gates: + name: Security gates + runs-on: ubuntu-latest + needs: [sast, secrets, workflows] + if: always() + permissions: + contents: read + steps: + # `needs` alone does not gate: a skipped or cancelled scan satisfies it. + # This fails unless every scan actually reported success. + - name: Every scan succeeded + env: + RESULTS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + failed=$(echo "$RESULTS" | jq -r ' + to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)" + ') + if [ -n "$failed" ]; then + echo "These scans did not succeed:" + echo "$failed" + exit 1 + fi + echo "Every scan succeeded." diff --git a/.gitignore b/.gitignore index 6d05ae2..2f1be16 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules/ .DS_Store +.env diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 0000000..dd5d0e1 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,11 @@ +# Read by SonarQube Cloud's Automatic Analysis, which scans the repository on +# its own and reports a quality gate on every pull request. It reads this file +# from the default branch only, so a change here takes effect once it is on +# main. +sonar.sources=. +sonar.exclusions=docs/assets/**,node_modules/**,**/.vstack/** +sonar.tests=plugins/vstack/skills/review/tests + +# The shared shell is stamped into every page by lib/build-shell.mjs, because a +# page has to work with no external requests. That duplication is the design. +sonar.cpd.exclusions=plugins/vstack/**/*.html diff --git a/CHANGELOG.md b/CHANGELOG.md index dbea803..7072bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,172 @@ 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.0 — 2026-08-09 + +**Changed** + +- **One place decides what the product looks like.** The palette now comes from + `design/tokens.css`, the design guide's token source, instead of being decided + in the shared shell. Every page — the review workspace, the story map, the + spec tree, the build board, the chooser — picks up the guide's Cavalry brand: + purple-cast neutrals in place of the cool greys, and the brand red at the step + that holds its contrast on white. Nothing moved and nothing was renamed; only + the colours changed. +- Dark is now part of that source rather than something the shell decided on its + own, so both themes come from the same place. + +## 6.3.0 — 2026-08-09 + +**Fixed** + +- **Codex never told you a new version was out.** The update banner only knew + how to recognise a Claude Code install, and the Codex profile had the check + switched off, so a Codex copy stayed on whatever release it was installed at + with nothing to say so. Codex installs are now recognised by the version + directory Codex unpacks them into, and the banner shows the two commands that + take the update: + + ```text + codex plugin marketplace upgrade cavalry-collective + codex plugin add vstack@cavalry-collective + ``` + + A running Codex thread keeps the copy it started with, so start a new thread + after updating. +- An install sitting behind a symlinked path — `/var` and `/tmp` on macOS, or a + home directory that has moved — was not recognised as an install, and got no + banner. + +## 6.2.0 — 2026-08-09 + +**Changed** + +- **Clear all no longer takes the comments you are still working on.** It clears + the addressed ones, and a checkbox on the confirm takes the open ones as well. + The box is off every time the dialog opens, so tidying the list can never lose + a comment you had not finished with. +- **The banner that announces a finished round no longer names a version.** It + says the round is done, which reads the same for a live app as for a + wireframe, and the button beside it is now **Refresh**. A live review never + advanced a version, so that banner had never appeared there at all. +- The handle that reopens the comments panel is part of the chrome again rather + than wearing the brand colour. Its count badge is what says comments are + waiting. + +**Added** + +- **`publish --summary "…"`** records the account of the round you would give in + chat, and the workspace shows it under the banner. It opens and closes on an + accordion chevron, and however you leave it is how the next round arrives. + One summary is kept, the latest; a publish without it clears it. +- **`reply --option "…" --option "…" --recommend `** turns a question into + answers to pick from, one marked *Recommended*. Pressing one answers with + those words, and the box to type something else is still there. +- **The comments panel is resizable.** Drag its inner edge, or focus it and use + the arrow keys. The width is remembered for this browser. +- A comment about the page as a whole now has a **Save** button and the + Shift+Enter hint, the same as one made on the page. Enter saves it as a draft + instead of sending it — it goes out with your next Send, like every other + comment. + +**Fixed** + +- **A comment closed while you watched dropped straight into the folded + "Earlier" group.** The workspace never picked up the close stamp from the + server, so everything read as closed long ago instead of standing where you + could check it. +- **A question showed a progress bar while it waited on you.** A comment whose + last word is the agent's no longer counts as work in flight: no bar, no place + in the "working on N" count, and it does not trip the stalled timer. +- The stack catalogue the parked `start` tool shows named packs the template no + longer has. + +## 6.1.0 — 2026-08-08 + +**Fixed** + +- **A second session in the same project was gated on a review it had never + seen.** With two agent sessions open in one directory, the Stop hook told the + uninvolved one it owed answers on another session's comments — naming the ids + and the command that would close them, which invited the wrong session to + finish someone else's round. Delivery now records the session it went to: the + watcher is started with `--session ` (the host adapter supplies the id), + and the gate asks `unanswered --session `, so it holds only the session + whose watcher took delivery. A delivery recorded without an identity gates no + one. + +**Added** + +- **A watcher never covers a review another session already covers.** + `watch --all` leaves a store alone while its `watching` heartbeat is fresh, + and takes it once that heartbeat is gone — so a second session's sweep cannot + take delivery of comments meant for the first. Naming the page with `--file` + still covers it regardless: that is the deliberate way to adopt a review from + a watcher that is stuck. + +## 6.0.0 — 2026-08-06 + +**Breaking** + +- **Rounds are gone.** A review is one list of comments, each open or closed, and + the agent is the only one who closes. `rounds/`, the `pending` sentinel, the + per-version comment copies and `feedback.json` are no longer written. `claim` + and `check` are removed: taking delivery is the tick itself, and there is no + unclaimed round to name. +- **`publish --addressed` is now `publish --close`,** and it no longer has to + account for every comment. What you close is closed; what you leave stays open + and comes back on the next delivery. `--label` is independent of it: either + flag alone is valid. +- **On-disk shape.** Comments live in `comments.json`; the brief is `brief.md`, + rewritten on each delivery. A store written by an earlier version is read where + it lies — newest copy of each id wins, `addressed` and dismissed both become + closed — and nothing is moved. +- **A version records no comments.** `versions/v.meta.json` is a label and a + date. Snapshots are for looking at. + +**Added** + +- **A round the agent took is finished before its turn can end.** `unanswered` + reports every comment the agent was handed and then said nothing about — + neither closed nor replied to. On Claude Code a Stop hook runs it and holds the + turn open until the round is answered. Nothing else caught this: a delivery + only fires when the reviewer writes again, so a comment the agent went quiet on + sat there for as long as they stayed quiet too. +- **Send again, when the agent stops responding.** After a minute with nothing + listening, the workspace says the agent has stalled rather than animating + progress that is not happening, and offers to put those comments back in the + queue. A comment already delivered is invisible to a watcher started + afterwards, so a restarted session used to sit idle on a review it could not be + handed. Refused while a heartbeat says an agent still holds them. +- **Hard reset**, in the cog. Starts a review over, behind a confirm that says how + many comments and versions go. The page under review is left alone and becomes + v1 again. + +**Fixed** + +- **What a failed action had to say could not be read.** A message raised while a + dialog was open sat under that dialog's own backdrop, dimmed and blurred by it. + Messages now join the top layer, so they arrive on top of the thing that + failed. +- **A comment could stop being closable.** Replying to one changed the + fingerprint its round had recorded, so the reviewer answering the agent's own + question was what blocked the comment from ever closing — and the workspace + held back the re-send that would have cleared it. Neither rule exists now, and + the protocol states the property that was missing: nothing can refuse a close. + An agent that has taken delivery can always finish. + +**Changed** + +- A comment's words are frozen when the reviewer sends it. Anything to add after + that is a reply, which means two writers can no longer disagree about what was + asked, and the merge heuristics that arbitrated them are gone. +- `question` is no longer a state — a comment waits on the reviewer when the last + reply is the agent's. Withdrawing a comment already delivered is a reply asking + for it back. Revert and Refine both write into the thread. +- The workspace holds no protocol state. Queued, being worked on, editable and + withdrawable are all read off the comment, so a reload or a second tab sees the + same review as the tab that wrote it. + ## 5.0.0 — 2026-08-05 **Breaking** diff --git a/CLAUDE.md b/CLAUDE.md index 6166d01..879a20a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ also ships a Codex manifest (`.codex-plugin/`) and a Grok host adapter host that discovers skills from a project directory gets instructions in its host adapter, not a checked-in skill directory that then has to be kept in sync. +`.claude/commands/` is the one exception, and it holds maintainer tooling only: +commands for working *on* this repo, which ship to nobody and mirror no part of +the plugin. `/ship` is the only one. A command there states no rule of its own — +it points at the section of this file that owns the rule. + Plain Node ≥ 18 ES modules, standard library only. There is no package.json, build step, bundler, or linter. (`node_modules/` at the root appears only when recording the README demo, which installs playwright-core.) @@ -29,6 +34,22 @@ Tests are standalone Node scripts — run them directly, one file per suite: node plugins/vstack/skills/review/tests/review-lifecycle.mjs # end-to-end review server round-trip node plugins/vstack/skills/review/tests/host-profiles.mjs # host profiles conform to host.schema.json node plugins/vstack/skills/review/tests/workdir.mjs # .vstack/local working-dir resolution +node plugins/vstack/skills/review/tests/round-gate.mjs # `unanswered` and the Stop hook that runs it +node plugins/vstack/skills/review/tests/update-check.mjs # per-host update detection and the banner it produces +node plugins/vstack/skills/review/tests/design-tokens.mjs # the shell's palette still matches design/tokens.css +``` + +The Gherkin end-to-end suite lives in `e2e/` — the one directory with a +`package.json`, kept outside `plugins/` so the plugin itself stays +dependency-free. It drives the real review server and CLI; a mock agent +(`e2e/support/mock-agent.mjs`) plays the agent role, so no model or API key is +involved. CI runs it under both hosts. + +```bash +cd e2e && npm ci +npx playwright install chromium # once, for the @browser scenarios +npx cucumber-js # VSTACK_HOST=claude (default) +VSTACK_HOST=codex npx cucumber-js # the same suite under the Codex profile ``` The shared UI shell is stamped into pages, not linked (see below): @@ -48,7 +69,27 @@ claude plugin validate ./plugins/vstack --strict # the plugin manifest `.github/workflows/ci.yml` runs all of the above on every pull request. -CI cannot install the plugin, so rehearse that locally before a release. +The security scans run in `.github/workflows/security.yml`, and a merge is +blocked until every one of them passes. Two of them run locally: + +```bash +gitleaks git --no-banner --redact --verbose # secrets, over the full history +uvx zizmor@1.29.0 .github/workflows/ # workflow audit +``` + +SonarQube Cloud analyses the repository on its own and reports a quality gate +on the pull request. It is configured by `.sonarcloud.properties`, which it +reads from `main` only, so a change there takes effect after the merge. + +`SECURITY.md` owns what each gate enforces, the rule that a finding is fixed +rather than silenced, and the rule that every action is pinned by commit SHA +with its version in a trailing comment. Adding a step that uses an action means +resolving that SHA with +`gh api repos///commits/ --jq .sha`. Declare each job's +`permissions` on the job, never at workflow level, so a new job cannot inherit +one it does not need. + +CI rehearses the install, and you can run the same thing locally. `CLAUDE_CONFIG_DIR` keeps it out of the real config: without it, a local-path marketplace is written to user settings and shadows the published `cavalry-collective` until it is removed. The source must be `./`, not `.`. @@ -61,6 +102,16 @@ CLAUDE_CONFIG_DIR=$SANDBOX/.claude claude plugin details vstack # what a user rm -rf $SANDBOX ``` +The same rehearsal under Codex. `CODEX_HOME` is what keeps it out of the real +config, and the directory has to exist before Codex will use it: + +```bash +export CODEX_HOME=$(mktemp -d)/codex && mkdir -p "$CODEX_HOME" +codex plugin marketplace add "$PWD" +codex plugin add vstack@cavalry-collective +codex plugin list # what a user sees +``` + Nothing above runs a review end to end. For that, load the plugin from disk and drive the skill in a real project: @@ -68,6 +119,26 @@ drive the skill in a real project: claude --plugin-dir ./plugins/vstack ``` +Codex has no equivalent flag, and no way to read a working copy live. Adding the +clone as a local marketplace **copies** it into +`$CODEX_HOME/plugins/cache/cavalry-collective/vstack//`, and Codex runs +that copy. So a change made in the clone reaches Codex only when you re-run: + +```bash +codex plugin add vstack@cavalry-collective # re-copies, even at the same version +``` + +Then start a new Codex thread, because a running one keeps the copy it started +with. `codex plugin marketplace upgrade` does not do this — it refuses on +anything but a Git source. + +A marketplace is keyed by the `name` in `.claude-plugin/marketplace.json`, so +the clone and the published repository are both `cavalry-collective` and cannot +be configured at once. Codex refuses the second one until the first is removed +with `codex plugin marketplace remove cavalry-collective`. Working on the plugin +in your real `~/.codex` therefore means giving up the published install until +you add it back. + ## Architecture ### Contracts / engine / adapters / profiles @@ -88,6 +159,13 @@ The layering rule that everything else follows (`plugins/vstack/contracts/README `--host` / `VSTACK_HOST` (default `claude`). Loaded via `lib/host.mjs`. - **On-disk roles are stable:** review threads use `by: "agent" | "reviewer"`. Older files may say `"claude"`; readers treat that as `"agent"`. +- **Hooks are adapter surface.** `plugins/vstack/hooks/hooks.json` is Claude + Code's only entry point into the plugin, and no other host reads it. It + registers one Stop hook, `hooks/round-gate.mjs`, which blocks the end of a + turn while a review comment the agent took delivery of is still unanswered. + The hook decides nothing itself: `review-server.mjs unanswered` owns what an + unfinished round is, so every host gets the same answer by running it. Rule 14 + of `contracts/review-loop.md` is what it enforces. ### Two engines, one live-link protocol @@ -95,8 +173,8 @@ The layering rule that everything else follows (`plugins/vstack/contracts/README a self-contained HTML page inside the workspace, or reverse-proxies a running app (`--app`) so the workspace shares an origin with what it annotates (that origin-sharing is why comments can attach to elements, not coordinates). CLI - subcommands (`publish`, `claim`, `reply`, `ack`, `share`, `status`, - `check`, `watch`) drive the protocol; sentinels and round records live on disk. + subcommands (`serve`, `watch`, `publish`, `reply`, `ack`, `share`, `status`, + `unanswered`, `reset`) drive the protocol; sentinels and round records live on disk. - `lib/json-bridge.mjs` — the live link for JSON-document pages (user-story-map, plus the experimental spec and phase-build tools): the page POSTs saves and bumps a seq counter the agent's watcher wakes on; agent edits are pushed back @@ -120,6 +198,14 @@ hand-edit a stamped region; page-specific controls go in `vstack:slot` blocks, which survive stamping. New pages register in the `PAGES` list in `build-shell.mjs`. +The palette itself is decided in [`design/`](design/README.md), not in the +shell. `design/tokens.css` owns the scales; `lib/shell/tokens.css` carries them +as the roles pages consume (`--surface`, `--ink`, `--brand`). It is a copy, +because a stamped page may fetch nothing — so changing a colour means editing +both files and running `stamp`, and `tests/design-tokens.mjs` fails when they +disagree. The type scale is the guide's; the families are not, since a webfont +is an external request. + ### On-disk state Every tool writes per-machine state under `/.vstack/local//` @@ -180,10 +266,11 @@ hosts, and `.claude-plugin/marketplace.json` repeats the Claude entry. `version` is declared, so it is what a host compares against to decide an update exists. **Pushing commits without bumping it ships nothing to anyone.** -- Bump `version` in both host manifests, and add the release to `CHANGELOG.md`, - in the release commit. -- Tag `vX.Y.Z` on the commit that lands on `main`. - `.github/workflows/release.yml` fails when the tag and the manifest disagree. +- Bump `version` in both host manifests, and add the `CHANGELOG.md` entry, in + the pull request that changes the plugin — not in a release commit afterwards. + `.github/scripts/check-version.mjs` fails the PR when either is missing. +- Never tag by hand. `.github/workflows/release.yml` tags `main` and publishes + the release from what the merge already declares. - MAJOR for a breaking change to a skill name, an on-disk path, or a protocol. MINOR for new behaviour. PATCH for a fix. - Orphaning a user's in-flight state is MAJOR, and it needs a `LEGACY` entry in @@ -193,38 +280,80 @@ exists. **Pushing commits without bumping it ships nothing to anyone.** installed before a version existed. Changing how the version is declared means changing that file. -### Cutting a release when asked - -When the user says to cut, ship, or publish a release, run this end to end. The -`main` ruleset requires a pull request, so nothing lands directly on `main`. - -1. **Decide the version.** Read the commits since the last tag, apply the semver - rule above, and tell the user the number you picked and why in one line. - Proceed on that number. Stop and ask only when the same set of commits reads - as either MINOR or MAJOR depending on how a breaking change is judged. -2. **Verify before proposing anything.** Run the tests, the shell check, both - validate commands, and the install rehearsal from *Commands*. A failure here - ends the release. Report it and fix it first. -3. **Branch.** `release/vX.Y.Z` off current `main`. -4. **Bump and record.** `version` in both host manifests, and a `CHANGELOG.md` - entry written from the merged commits, newest first, with breaking changes - called out. -5. **Open the PR.** Title `vX.Y.Z — `. The body is - the changelog entry, so it can be reused as the release notes. -6. **Watch CI.** `gh pr checks --watch`. Every check must pass. A red - check means fix it on the branch and watch again, never merge past it. -7. **Merge when green.** Squash. The user has standing approval for this merge - and for the tag and release that follow, so do not ask again for a release - they asked for. -8. **Tag `main`.** Pull the squashed commit, tag it `vX.Y.Z`, and push the tag. - The release workflow re-checks the tag against the manifest. -9. **Publish the GitHub release** with the changelog entry as its notes, then - give the user the release URL. - -Stop and report rather than working around a problem: a red check that is not -yours to fix, a ruleset that rejects the merge, or a tag that already exists. - -Nothing here is a dry run. Every step from 5 onward is public. +### Releasing + +Merging to `main` is the release: this repository is what a user installs, so +the code is live the moment it lands. The version and the changelog entry +therefore belong in the pull request that changes the plugin, and a release is +not a separate piece of work. + +When a pull request touches `plugins/`, include in the same branch: + +1. `version` raised to the same value in both host manifests, by the semver rule + above. +2. The matching `CHANGELOG.md` entry, newest first, breaking changes called out. + This is published verbatim as the release notes, so write it for a user. + +The `Plugin changes ship a version` check fails the PR without both. Nothing +downstream can catch this: a merge that leaves the version alone publishes the +code and tells nobody, and the only repair is a second release. + +After the merge, `.github/workflows/release.yml` tags `main` as `vX.Y.Z` and +publishes the GitHub release. It keys on whether that version is already tagged, +so it is safe to re-run and does nothing on a merge that changed no version. + +Never tag by hand, never publish a GitHub release by hand, and never bump a +version on `main` outside a pull request. A wrong version is fixed by the next +release. + +When the user asks about a release that has already happened, read the state +rather than doing anything. `gh release list --limit 3` and `git log --oneline +origin/main -3` say whether it published. A version on `main` with no tag means +the Release workflow failed, and `gh run list --workflow Release --limit 3` says +why. Nothing changed under `plugins/` means there is nothing to ship, which is an +answer rather than a reason to invent a version. + +### Shipping a change when asked + +`/ship` runs this. It is also what to do whenever the user says to ship, land, +release, or merge the work on the current branch. The user asking for it is +standing approval for the pull request and the merge, so do not ask again. + +Everything from step 2 is public. + +1. **Check the branch carries what it must.** Run the tests, the shell check and + both validate commands locally first — a red check you could have caught is + wasted round-trips. If anything under `plugins/` changed, the version and the + `CHANGELOG.md` entry go in now, per *Releasing* above. Say which version you + picked and why, in one line. +2. **Open the pull request.** Branch off `main` if the work is not already on + one. The title is the sentence a reader sees in `git log`; the body says what + changed and how it was driven end to end. +3. **Watch both channels until they settle.** + - `gh pr checks --watch`. Every required check must pass. + - `gh pr view --comments` and `gh api + repos/Cavalry-Collective/visual-stack/pulls//comments` for review + threads. SonarQube and the review bots comment here rather than only failing + a check, so a green check list is not the whole picture. +4. **Fix on the branch and push.** Then watch again. A security finding is fixed, + never silenced or ignored. Answer a review comment that you are not acting on, + rather than leaving it unanswered. +5. **Merge when everything is green.** Squash. +6. **Confirm what it published.** A version bump tags `main` and publishes the + release within about a minute. Give the user the release URL. A merge that + carried no version bump publishes nothing, which is correct — say so. + +Stop and report instead of working around a problem: + +- The same check fails twice with the same error after your fix. Name what you + tried. +- A failure that is not yours: a service outage, a rate limit, a check that + passes on `main`. +- A review comment that asks for a decision the user has not made. +- The ruleset rejects the merge. + +Never merge with `--admin`, never bypass a ruleset, and never turn a check off to +get past it. ### Contributor-facing files @@ -333,21 +462,91 @@ tooling-agnostic. ## Demo recordings (README GIFs) -Use these dimensions for every demo recording — they were tuned so the text -reads clearly in the README: - -- **Browser viewport 920 × 760**, and export the GIF at native resolution — - never downscale the frames. -- **Review the demo page at phone width** (the workspace's 390px size) with the - canvas zoom locked at 100%. The workspace refits zoom on every version load - (size switch, Review changes, timeline scrub), so a recording script must - pin it — set zoom to 1 and no-op the refit for the session. -- Keep the subject app trivially simple (the todo list works well) so the - before/after change is obvious at a glance. -- Keep it snappy: fast typing, short holds, ~1.4× speedup at assembly, and - clamp idle gaps (e.g. the round-trip wait) to ~0.5s. -- Target: ~12 seconds, under 1 MB, saved to `docs/assets/wireframe-demo.gif`. - -Recordings are scripted — headless Chrome via playwright-core driving the real -review server end to end (publish v1, comment, send, claim, publish v2), with -frames captured as JPEGs and assembled with ffmpeg (two-pass palette). +`docs/assets/wireframe-demo.gif` is regenerated by one command. It needs +playwright, which the e2e suite already depends on, and ffmpeg. + +```bash +cd e2e && npm ci # once — the recording borrows playwright from here +node docs/demo/record-demo.mjs +``` + +Nothing about the recording is staged. `docs/demo/record-demo.mjs` publishes +`docs/demo/pages/v1.html` to a real review server, drives the shipped workspace +in headless Chrome the way a reviewer would, and plays the agent's turn through +the review CLI — take delivery of the round, swap in `pages/v2.html`, publish it +back. A change to the workspace shows up in the next recording, so re-record +after one. + +**To change what the demo says**, edit the two pages and the note constants at +the top of the script. v2 must answer every mark v1 receives, and nothing else: +the demo is a review round, so an unexplained change reads as the agent going +off on its own. + +What the recording shows, in order: a point comment on the compose row, an area +comment dragged over the task list, a strike dragged through the words "all +completed tasks" in the footer button, Send, the agent's banner, then Review +changes and the new version. All three marks carry a written note, including the +strike — a mark that needs no words still reads better beside the two that have +them. + +**Every mark must land where the reader can see the result.** The change each +one asks for has to be inside the visible canvas in v2, which is shorter than +the phone frame — a footer that fits in v1 can fall below the fold once v2 adds +a row. The script sends only after the review holds all three marks, so a +gesture that captured nothing fails the run instead of shipping a demo of two. + +These are tuned for how the GIF reads in the README. Keep them: + +- **Browser viewport 920 × 760**, exported at native resolution. Never + downscale the frames. +- **The demo page is reviewed at phone width** (the workspace's 390px size) + with the canvas zoom locked at 100%. The workspace refits zoom on every + version load (size switch, Review changes, timeline scrub), so the script + pins it — set zoom to 1 and no-op the refit for the session. +- **The subject app is a plain light todo list**, and v2 is a small targeted + edit to it. A redesign between versions reads as a different app rather than + as feedback being applied. +- **Fast and snappy**: ~15ms per typed character, ~340ms of eased cursor + movement per leg, and no deliberate holds. Assembly caps any state that sits + still at 400ms, and the closing frame at 0.55s — the GIF loops, so a long + look at the result is time the reader spends waiting to see it again. +- **Comments are ordinary review notes** — a full sentence naming the change. + No jokes. +- Target: ~11 seconds at 25fps, under 1 MB. + +These are load-bearing and easy to undo by accident: + +- **Capture frames as PNG.** A lossy re-encode perturbs every block in the + frame, so two frames differing only by the cursor differ everywhere and the + GIF encoder can no longer skip what did not change. PNG is worth about half + the file size. +- **Draw the cursor from real mouse events.** A screenshot contains no pointer, + so the script injects one and moves it from the page's own `mousemove`. Being + told where the pointer is instead would let the drawing and the input drift + apart. +- **Watch with `watch --stream`.** The one-shot form has to exit to deliver a + round, and the top bar reads Unlinked in the gap before something is watching + again. The streaming form stays up for the whole recording, which is also what + a real session runs. It goes live only once its handshake is answered, so the + script reads the token off its own output and acks it. +- **Tools are picked by their shortcut**, not by clicking the toolbar. Sending + the pointer up to the bar and back is a long move away from the page for + something a reviewer does with one key. A key pressed while a composer has + focus is text, so the composer is closed and blurred first. +- **A press only starts a gesture on clear canvas.** The workspace ignores a + press that lands on an existing mark, and spends one on an open composer + closing it — so the script shuts the composer before the strike. A selected + mark also draws its note beneath itself, which is why the page keeps the + struck control well below the list the area comment covers. Space in the + layout is what keeps them apart; do not close the gap. +- **A text strike needs both ends inside the words.** A point just past the last + character is in no text node, no caret resolves there, and the strike silently + takes nothing. + +## UI tweaks and composition reuse + +- **Existing screens are the baseline for tweak work.** Before changing an existing screen, inspect the current implementation in the affected state and viewport, using the running app or the review context already supplied with the request. Treat the request as a delta, not permission to redesign: preserve its layout, hierarchy, spacing, typography, component variants, copy, states, and responsive behaviour unless the request explicitly changes them. A tweak never re-derives the screen from the design guide or its original mockup; the existing app is the reference. +- **Reuse components and compositions.** Before adding or changing a UI action, search the same feature and then the app for the most comparable existing instance of that action or composition. Reusing the same atom is not sufficient: in comparable contexts, also match placement, density, label shape, loading/disabled behaviour, and responsive treatment. If the new instance deliberately differs, record the contextual reason. +- **One owner at the second comparable instance.** When the same semantic composition appears twice in comparable contexts, give it one implementation in the same change: reuse or extend the owning molecule/organism, or extract one at the appropriate tier. Share a coherent interaction, not merely a bundle of matching classes. If the contexts require meaningfully different behaviour, keep them separate and document the distinction. +- **Verify without duplicating the visual workflow.** For a tweak to an existing screen, reuse the running screen or supplied review capture as the baseline; no separate before/after capture is required. Confirm the requested delta is present and unrelated visual structure is unchanged. +- **Self-review the baseline.** For every UI composition added or changed, name the comparable existing instance or documented pattern used as its baseline — or say why none fits — and confirm that unrelated visual structure was preserved. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de78968..406403c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,14 +20,40 @@ CI runs on every PR and repeats what you can run locally: node plugins/vstack/skills/review/tests/review-lifecycle.mjs node plugins/vstack/skills/review/tests/host-profiles.mjs node plugins/vstack/skills/review/tests/workdir.mjs +node plugins/vstack/skills/review/tests/round-gate.mjs node plugins/vstack/lib/build-shell.mjs check claude plugin validate . --strict claude plugin validate ./plugins/vstack --strict ``` -Nothing runs end to end in CI, so a green build is not a tested skill. +The Gherkin suite in `e2e/` drives the review server and CLI end to end with a mock agent in the agent's seat, under both hosts: -CI also cannot install the plugin. Rehearse that locally before a release, with `CLAUDE_CONFIG_DIR` pointed at a throwaway directory so the local-path marketplace is not written to your real settings, where it would shadow the published `cavalry-collective`: +```bash +cd e2e && npm ci +npx playwright install chromium +npx cucumber-js +``` + +It exercises the protocol, not a model, so a green build is still not a skill you have driven yourself. + +## Passing the security gates + +CI also runs the scans listed in [SECURITY.md](SECURITY.md), and a merge is blocked until all of them pass. Two of them you can run before you push: + +```bash +gitleaks git --no-banner --redact --verbose # every commit, not the working tree +uvx zizmor@1.29.0 .github/workflows/ # only if you touched a workflow +``` + +CodeQL and SonarQube Cloud report in the pull request itself. Read the finding before assuming it is noise — the servers read from disk and the pages build DOM from stored comments, which is exactly where a real one would appear. + +When you add a step that uses an action, pin it by commit SHA and put the version in a trailing comment, the way the existing steps do. Copy the SHA from the release you intend to use: + +```bash +gh api repos///commits/ --jq .sha +``` + +CI rehearses the install a user performs. Run the same thing locally when you touch a manifest, with `CLAUDE_CONFIG_DIR` pointed at a throwaway directory so the local-path marketplace is not written to your real settings, where it would shadow the published `cavalry-collective`: ```bash SANDBOX=$(mktemp -d) @@ -41,19 +67,21 @@ rm -rf $SANDBOX ## Cutting a release -Both host manifests declare a `version`, and that version is what Claude Code and Codex compare against to decide an update exists. **Pushing commits without bumping it ships nothing to anyone.** +There is nothing to cut. Merging to `main` is the release, because this repository is what a user installs — the code is live for everyone as soon as it lands. + +So the version travels with the change rather than following it. A pull request that touches `plugins/` must also carry: -1. Branch `release/vX.Y.Z` off `main`. -2. Bump `version` to the same value in `plugins/vstack/.claude-plugin/plugin.json` and `plugins/vstack/.codex-plugin/plugin.json`. -3. Add the release to `CHANGELOG.md`, newest first. -4. Open a PR. `main` takes no direct pushes, and a release is not an exception. -5. Merge when CI is green, then tag `vX.Y.Z` on the squashed commit on `main`. The release workflow fails when the tag and the manifest disagree. -6. Publish the GitHub release with the changelog entry as its notes. +1. `version` raised to the same value in `plugins/vstack/.claude-plugin/plugin.json` and `plugins/vstack/.codex-plugin/plugin.json`. +2. The matching entry in `CHANGELOG.md`, newest first. It is published verbatim as the release notes, so write it for a user rather than for a reviewer. + +CI fails the PR when either is missing. Nothing downstream can catch it: a merge that leaves the version alone publishes your code and tells nobody, because a host decides an update exists by comparing the version it installed against the one `main` declares. The only repair is a second release. Version to semantic versioning: MAJOR for a breaking change to a skill name, an on-disk path, or a protocol; MINOR for new behaviour; PATCH for a fix. Orphaning a user's in-flight state is a MAJOR change, and it needs a `LEGACY` entry in `lib/workdir.mjs` rather than a migration. +Once it merges, `.github/workflows/release.yml` tags `main` as `vX.Y.Z` and publishes the GitHub release with your changelog entry as its notes. Do not tag by hand and do not write a release by hand. A merge that changed no version publishes nothing, which is what a docs or CI change should do. + ## Reporting problems - A bug or an unclear skill: open an issue. diff --git a/README.md b/README.md index d972f94..1f6556a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ # Visual Stack +[![CI](https://github.com/Cavalry-Collective/visual-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/Cavalry-Collective/visual-stack/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + + + + ## Stop prompting. Start pointing. Visual Stack adds a Figma-like feedback layer to AI coding agents. @@ -71,7 +86,7 @@ As feedback becomes more visual, chat becomes the bottleneck. Context gets burie Visual Stack keeps every comment attached to the element, route, and version it refers to. Your agent receives the feedback with the visual context intact. -No archaeology through 200 messages. No screenshot named `final-final-v2-actually-final.png`. No arguing about which blue. +No scrolling back through the chat. No screenshot graveyard on your desktop. No describing what you could just point at. ## Requirements @@ -93,6 +108,10 @@ Each workspace is linked to one agent session. The link holds while that session ![Your comments are submitted as one review round. The agent claims the round and reads its brief, asking for clarification when a comment is unclear. Comments sent while the round is in progress join it. Publishing is blocked until every comment has been applied, answered, or dismissed, and the published version appears in the same workspace.](docs/assets/review-lifecycle.svg) +## Security + +Installing this plugin runs its code on your machine, so every change to `main` passes a set of scans before it lands: static analysis, a secret scan over the whole history, a workflow audit, and a code-quality gate. The badges above report the last run. [SECURITY.md](SECURITY.md) says what each gate enforces and how to report a vulnerability. + ## Contribute Visual Stack is open source and under active development. Expect rough edges, breaking changes, and occasional moments of character development. diff --git a/SECURITY.md b/SECURITY.md index 035f74a..b7263ae 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,6 +19,28 @@ In scope: Out of scope: vulnerabilities in Claude Code itself — report those to Anthropic. +## What runs on every change + +`.github/workflows/security.yml` runs these scans on every pull request, on every push to `main`, and weekly. The `Security gates` job fails unless all three report success, so a scan that is skipped or cancelled blocks the merge in the same way a failing one does. + +| Scan | Tool | What it enforces | +|---|---|---| +| Static analysis | CodeQL, `security-and-quality` suite | Injection, path traversal, and unsafe DOM construction in the servers and the pages. Findings land in the repository's Security tab. | +| Secret scan | Gitleaks | No credential in any commit. It reads the full history, not the working tree, because anything ever committed is compromised. | +| Workflow audit | zizmor | The workflows themselves: token permissions, credential persistence, and untrusted input reaching a `run` block. | + +Two scans run outside that workflow, on SonarSource's and OpenSSF's infrastructure rather than ours: + +- **SonarQube Cloud** analyses the repository automatically and reports a quality gate on each pull request as its own check. It covers bugs, security hotspots, and maintainability on new code, and it is free for public projects. `.sonarcloud.properties` configures it, and SonarQube Cloud reads that file from `main` only. +- **OpenSSF Scorecard** runs from `.github/workflows/scorecard.yml`, weekly and on `main`. It rates the repository rather than the code — branch protection, pinned actions, token permissions — and publishes the score the README badge reads. + +Both report a check on the pull request. Requiring them is a branch protection setting, not something a workflow can enforce. + +Two rules keep those gates meaningful: + +- Fix a finding rather than silencing it. A suppression carries a comment saying why it cannot be fixed, next to the line it applies to. +- Actions are pinned by commit SHA with the version in a trailing comment. A tag moves, and a moved tag runs code nobody reviewed. Dependabot proposes the bumps weekly. + ## Supported versions `main` only. There are no maintained release branches. diff --git a/design/CLAUDE.md b/design/CLAUDE.md new file mode 100644 index 0000000..da9587f --- /dev/null +++ b/design/CLAUDE.md @@ -0,0 +1,35 @@ +# Working in design/ + +Rules for changing the visual source. What it is and how it reaches the pages is +in [`README.md`](README.md). + +## The token source + +- **One source.** `tokens.css` owns the palette, type, space, shape and + elevation scales. `plugins/vstack/lib/shell/tokens.css` carries the same + values as the roles pages consume. Change this file, then the shell, then run + `node plugins/vstack/lib/build-shell.mjs stamp` — all in the same commit. +- **Extend the scale, never the screen.** A page that needs a value which is not + here gets a new step here. Hard-coding a colour in a page is how two greys + that should have been one end up on the same screen. +- **Three tiers.** Primitives are raw scales and are where a rebrand happens. + Semantic roles name what a value is for, and are what everything else reads. + Component overrides exist only where a component genuinely differs. +- **Every colour role is defined for light and dark.** A page renders in both, + and a role that exists in one is a hole in the other. + +## The guide + +- `design-guide.html` is the page to open when judging whether a change still + reads as one product. Keep it showing what the tokens currently are, not what + they were. +- It is a page like any other Visual Stack ships: self-contained, no build step, + no external requests, works opened off disk. + +## What this folder does not decide + +- **Behaviour.** How the review loop works is `plugins/vstack/contracts/`. +- **Page structure.** The shared top bar, scrubber and their scripts are + `plugins/vstack/lib/shell/`, stamped into pages rather than linked. +- **Anything host-specific.** A product name reaches a page as data from a Host + profile, never as a value here. diff --git a/design/README.md b/design/README.md new file mode 100644 index 0000000..ac0910c --- /dev/null +++ b/design/README.md @@ -0,0 +1,47 @@ +# design — the visual source for every page Visual Stack ships + +This folder decides what the product looks like. `tokens.css` holds the palette, +type, spacing, shape and elevation scales; `design-guide.html` shows them +rendered, and is the page to open when deciding whether a change reads as the +same product. + +Everything Visual Stack ships is a page: the review workspace, the story map, +the spec tree, the build board, the chooser. They are the screens this guide +governs. There is no separate application. + +## How the tokens reach the pages + +A page never links this folder. Every page has to work three ways — served over +http, opened off disk, and inlined into an Artifact under a CSP that blocks all +external requests — so nothing is fetched at runtime. + +1. `tokens.css` is the source: the primitive scales, and the role each one fills. +2. `plugins/vstack/lib/shell/tokens.css` carries those values as the roles pages + actually consume — `--surface`, `--ink`, `--brand`, `--line`. +3. `plugins/vstack/lib/build-shell.mjs stamp` copies the shell into every page + between its `vstack:shell` markers. + +So a colour changes here, and reaches the product on the next stamp. Run +`build-shell.mjs check` to find a page that has drifted. + +## Rules that come from the pages, not from the guide + +- **Roles, not colours.** A page asks for `--surface`, never for white. That is + what lets one stylesheet serve light and dark. +- **Both themes are first-class.** Every page supports the OS preference and an + explicit choice. A value added for light needs its dark counterpart in the + same change. +- **System fonts only.** The type scale is honoured; the families are not. A + webfont is an external request, which an Artifact's CSP blocks and a file on + disk cannot make, and embedding faces would land in every stamped page. +- **Page-specific hues stay in the page.** The story map's phase bands, the + board's new/have/touch, the spec's priorities mean something only there. This + folder holds what is shared. + +## Where the rest of the rules live + +- Working on the pages, the stamped shell and the build: the root + [`CLAUDE.md`](../CLAUDE.md) → *Self-contained pages and the stamped shell*. +- Changing an existing screen: root `CLAUDE.md` → *UI tweaks and composition + reuse*. +- Working in this folder: [`CLAUDE.md`](CLAUDE.md). diff --git a/design/design-guide.html b/design/design-guide.html new file mode 100644 index 0000000..737164c --- /dev/null +++ b/design/design-guide.html @@ -0,0 +1,2193 @@ + + + + + +Keystone — Design Guide + + + + + + + + +
+ + + +
+ +
+

Keystone · design guide

+

Introduction

+

Cavalry ships many applications, and they should feel like the work + of one team. This guide is the design standard behind all of them: the principles, + tokens, and rules that define how our products look, behave, and communicate.

+

Every project chooses its own tech stack and component library, so this guide + deliberately stops short of implementation. It fixes the design decisions — the + visual language, the patterns, the values — and leaves each stack free to apply + them. Stating the expectations once, here, lets every new project start consistent + and scale quickly without restating them.

+
    +
  • Starting a project: rebrand by editing the primitive tier of + the design tokens; every page of this guide re-renders from the new values. + Review it in a browser before building any screen.
  • +
  • Building a screen: choose its screen archetype first, and + build with semantic tokens only.
  • +
  • Reviewing: check each chapter's closing rules; every + violation is a defect.
  • +
+
+

No special cases. When a shared building block cannot do what + a screen needs — a token scale missing a value, a component missing a feature — + extend the block, then use it. Never build a one-off inside a screen.

+
+
+ +
+

Start

+

Principles

+

Every principle serves one goal: reduce the user's cognitive effort. + The system adapts to the user's mental model, not the other way around. Six + principles in priority order — when two conflict, the earlier one wins.

+
+
01

Familiarity over novelty

+

Prioritize established design patterns over original ones. Users should immediately understand how to interact with the system without needing to learn a new interface. Every page should feel familiar, predictable, and consistent with the rest of the product.

+
02

Content before commentary

+

Interfaces exist to help users work with data, not read explanations. Keep instructional text and decorative copy to a minimum. Every word should have a purpose.

+
03

Make structure visible

+

Information is rarely flat. Use visual hierarchy, grouping, spacing, nesting, and typography to reflect relationships between data so users can quickly understand how information is organized.

+
04

Progressive disclosure

+

Present information and functionality only when it is needed. Use expandable sections, nested views, secondary pages, and contextual controls to reduce cognitive load while keeping advanced functionality easily accessible.

+
05

Guide the primary journey

+

Every screen should make the intended workflow obvious. The primary action should stand out through placement, visual emphasis, and button hierarchy, allowing users to complete common tasks with minimal thought.

+
06

Motion with purpose

+

Animation should communicate, not decorate. Use motion to soften transitions, indicate cause and effect, provide feedback, direct attention, and make waiting feel more natural. Motion should never distract from the task.

+
+
+ +
+

Start

+

Tokens

+

Every design decision is a named token. Screens reference roles, + never raw values.

+

A design token names the role of a value: a button references + --primary, never #e02b20. Screens contain no hard-coded + colour, spacing, typography, or motion values, so changing a token updates every + screen that depends on it. In this template the tokens live in one file, + tokens.css.

+ +

Token hierarchy

+

The tokens are organised in three tiers.

+
+
+
1 · primitiveRaw design values — the brand palette, spacing scale, type scale, motion durations. Only this tier changes in a rebrand. --red-400 · --space-3 · --duration-fast
+
↓ referenced by
+
2 · semanticNamed design roles, mapping intent to primitives — what screens and components consume. --primary · --muted-foreground · --gutter-screen
+
↓ referenced by
+
3 · componentComponent-specific values, created sparingly — only when a reusable component needs values shared nowhere else. --control-height · --box-padding
+
+
A rebrand touches tier 1; screens consume tier 2; tier 3 grows only as + components stabilise.
+
+ +

How tokens resolve

+

A screen asks for a role; the role resolves through the tiers to one raw value, + set once.

+
+
+
screen asks"What colour is the primary action?" → --primary
+
resolves to ↓
+
semantic--primary: var(--red-400) — the role picks a step
+
resolves to ↓
+
primitive--red-400 = — the raw value, set once
+
+
Changing the primitive updates every component that references it; the + screen never changes. Every annotation in this guide is read from the tokens the + same way, at load.
+
+ +

Naming

+

Name tokens by purpose, not appearance. --primary · + --muted-foreground · --gutter-screen survive a rebrand; + --red · --light-grey · --large-padding become wrong the + day the brand changes.

+ +

Theming

+

There is one light theme today. A future theme — dark, or a brand variant — + remaps the semantic tier only; screens and components need no changes.

+ +

Framework compatibility

+

Semantic colour roles follow shadcn/ui's theme vocabulary + (--background, --primary, + --muted-foreground …), so components can be adopted with minimal + modification. One intentional difference: --primary is the primary + action colour, while shadcn's --accent is reserved for low-emphasis + interactive surfaces such as hover states.

+ +
    +
  • Screens use semantic tokens only. Semantic tokens reference primitives; + component tokens reference semantic or primitive tokens as required.
  • +
  • No hard-coded colour, spacing, typography, duration, or radius inside a + screen — a hex or px literal is a violation, and a text search finds every + instance.
  • +
  • If a required token does not exist, add it to the shared token system; + never introduce a one-off value.
  • +
  • Semantic names describe purpose, not appearance.
  • +
+
+ +
+

Foundation

+

Colour

+

A small, consistent palette: neutral surfaces with a subtle purple + cast, Cavalry red for primary actions, and supporting colours for semantic + meaning.

+

Screens never reference raw colours directly. They consume semantic colour + tokens, which resolve to the underlying colour ramps + (Tokens).

+ +

Semantic colour roles

+

Semantic tokens describe the purpose of a colour rather than its appearance. + These are the only colour tokens screens use.

+
+ +
TokenValuePurpose
+ +

Colour ramps

+

Semantic tokens resolve to primitive colour ramps. Each colour family is a small + ramp of numbered steps, darker steps giving greater contrast; the neutral ramp + carries six steps to cover surfaces, borders, and text. Screens never reference a + ramp step directly. Hover a step for its value.

+
+ +

Intent formula

+

Status colours follow one structure throughout the system.

+
+ + + + + +
PurposeRamp step
Subtle background50
Border200
Interactive elements and content400
Text on tinted backgrounds600
+

The primary action colour adds hover (500) and pressed + (600) states. Supporting intent colours carry no interactive states, + so four steps is their whole ramp.

+ +

Accessibility

+

Every semantic colour pairing meets WCAG AA — verified, not assumed: 4.5:1 for + body text, 3:1 for large text and UI elements. Brand-vivid values that cannot hold + 4.5:1 (--red-300, the pure brand red, and --amber-400) + are for illustrations, decorative accents, and large graphics only — never + body-size text on a light background.

+ +

Meaning

+

Colour reinforces meaning but is never the only indicator. Pair it with icons, + labels, typography, and layout so every state remains understandable without + colour perception.

+
+
+
✕ Payment failed
+
Do — one colour family on the intent formula; the icon and text carry the meaning.
+
+
+
+
Don't — a bare red dot carries meaning by colour alone.
+
+
+

Destructive actions share the same brand red as primary actions. They are + distinguished through verb-first labels, confirmation flows, placement, and + context — never by introducing a separate red.

+ +
    +
  • Screens use semantic colour tokens only — never a ramp step, never a + hard-coded value.
  • +
  • Every colour pairing meets AA: 4.5:1 for body text, 3:1 for large text + and UI.
  • +
  • One semantic colour family per message or status.
  • +
  • Never rely on colour alone to communicate meaning.
  • +
  • Reserve the brand red for the primary action, links, and the focus + indicator.
  • +
+
+ +
+

Foundation

+

Typography

+

Typography creates clear hierarchy while staying quiet: as few + sizes and weights as necessary, so information rather than styling carries + the emphasis.

+

The system uses Space Grotesk for display text and headings, and Inter for all + interface and body text. When neither font is available, both fall back to the + system font stack.

+ +

Type scale

+

The type scale is a small set of reusable roles; the specimens below render from + the live tokens.

+
+ + + + + + + + + + +
RoleSpecimenTokenUse for
DisplayDisplay600 · display · boldCovers, heroes, full-page utility
TitlePage title500 · display · boldOne per page
HeadingSection400 · display · boldSections, cards, dialogs
BodyThe reading default.300 · regularParagraphs, forms
SecondaryLabels, tables, dense UI200 · regular/mediumControls, tables
CaptionMeta, helper text100 · regularDe-emphasized text
OverlineGroup label100 · capsGrouping labels
Metric12,480400 · tabularKPIs, dashboard stats
Codesum(rows)monoCode, ids
+

The scale is intentionally limited. Most screens need no more than four type + sizes and two font weights.

+ +

Hierarchy

+

Typography communicates structure before decoration. Each screen carries one + page title, and heading levels descend without skipping. Express hierarchy with + size first, before reaching for additional weights or colours; introduce a new + size only by extending the shared scale, never as a one-off value.

+ +

Line height

+

Reading text prioritises comfort; interface text prioritises alignment. Body + copy uses the base reading line height. Controls, labels, table + cells, and other dense UI use tight, so rows align consistently to + the spacing rhythm rather than optimising for long-form reading.

+
+
+
LabelInput values, one-liners, and checkbox labels use tight line-height and stay aligned in their row.
+
Dotight inside components: the row stays on the rhythm.
+
+
+
LabelBody line-height inside a component row pushes the rhythm apart and misaligns columns.
+
Don'tbase line-height breaks component alignment.
+
+
+ +

Numbers

+

Use tabular figures wherever values are compared vertically — tables, financial + data, dashboards, metrics, counters. Inside normal sentences, proportional + figures let the text flow naturally.

+
+
+
88,888.88
11,111.11
+
Do — tabular figures in tables and metrics: digits align, values compare.
+
+
+

You have 1,298 unread messages.

+
Don't — tabular digits inside a sentence stick out; prose uses proportional figures.
+
+
+ +

Wrapping & truncation

+

Text wraps by default, and layouts accommodate varying content lengths rather + than assuming fixed-height text. Truncation is reserved for genuinely fixed-width + slots such as a table cell or a list-row title: one line, an ellipsis, and the + complete value reachable through a tooltip, detail view, or equivalent. Page + titles, headings, labels, and action text never truncate — rewrite the copy + instead. Long unbroken strings (URLs, emails) wrap mid-string rather than + stretch their container; data values are the exception and never truncate or + split (Data formatting).

+
+
+
Meeting notes — Acme Corp renewal call, 12 Jan
+
Do — a fixed slot ellipsizes one line; the full value stays + reachable.
+
+
+
Save cha…
+
Don't — labels, headings, and actions never truncate; + reword them instead.
+
+
+
    +
  • Use the shared type scale only; a new size is a scale step, never a + one-off value.
  • +
  • Prefer four sizes and two weights per screen.
  • +
  • One page title per page; no skipped heading levels.
  • +
  • Body text stays within the reading measure (--measure).
  • +
  • UI text — buttons, badges, table headers, card values — is a + full string set in medium; bolding a word inside a + sentence uses strong, a separate role.
  • +
  • Tabular figures for comparable numeric data only.
  • +
  • Wrap by default; truncate only in fixed-width slots with the full value + reachable — never headings, labels, or buttons.
  • +
+
+ +
+

Foundation

+

Spacing

+

Spacing communicates relationships: elements that belong together + sit closer together, and every increase in spacing signals a stronger + separation.

+

Enterprise applications also use screen space efficiently. Prefer compact + layouts that maximise information density without compromising readability, + hierarchy, or ease of interaction — and remove decorative whitespace before + reducing spacing that communicates structure.

+

The system uses a six-step spacing scale. Related elements sit one step closer + together than unrelated elements.

+
+ +

Container padding

+

Padding inside a container is managed separately from spacing between + components: the spacing scale controls the distance between elements, the + box scale controls the space inside containers. Three sizes, + with the default in the middle and one size per column.

+
+
+
box-sm · dense tables
+
box · cards, dialogs (default)
+
box-lg · spacious panels
+
+
--box-padding-sm / --box-padding / --box-padding-lg — + the spacing scale governs space between siblings; boxes the padding within a container.
+
+ +

Information density

+

Users of enterprise systems spend most of their time scanning, comparing, and + editing information. Interfaces maximise the useful content visible at once + without feeling cluttered: compact layouts reduce scrolling, improve comparison + between related information, and increase efficiency. Prefer tighter layouts + over excessive whitespace, keep related information close, and group it into + clear visual sections — but preserve the spacing that communicates hierarchy, + and add no whitespace purely for aesthetics.

+ +

Vertical rhythm

+

Spacing, typography, and component dimensions work together: all land on the + same underlying 4px grid, so rows align naturally between neighbouring columns + and components and users can scan quickly. Text within components uses tighter + line heights so controls stay aligned to the grid.

+
+
+
Heading
+ +
Text blocks separated by uniform steps read as one column…
+ +
…so adjacent columns align row-for-row across a screen.
+
+
Line-heights and control heights land on the 4px grid — the rhythm is why + component text uses tight.
+
+ +

Page-level spacing is defined by semantic tokens — --page-title-gap, + --page-section-gap — rather than re-derived per page. These values + belong to the screen archetype and stay consistent + across the application.

+ +
    +
  • Use the spacing scale for all gaps between elements; use the box scale for + container padding.
  • +
  • Keep related elements one spacing step closer together than unrelated + elements.
  • +
  • Prefer compact layouts that maximise information density. Remove decorative + whitespace before structural spacing, and preserve the spacing that + communicates hierarchy and grouping.
  • +
  • No arbitrary spacing values. If the scale cannot express a layout, extend + the shared scale rather than the instance.
  • +
  • Maintain one consistent vertical rhythm through the page; page-level gaps + use their archetype-fixed tokens.
  • +
+
+ +
+

Foundation

+

Layout

+

One consistent frame for every screen: the responsive grid, + breakpoints, gutters, and content widths every page inherits.

+

Pages never recreate these values. They inherit them from the shared layout, + so every page behaves consistently across the product.

+ +

Responsive grid

+
+ + + +
12 columns ≥ lg · 8 from sm · 4 below. Columns are percentages; + gutters fixed at --space-3; margins are --gutter-screen.
+
+

Columns scale proportionally with the viewport while gutters stay fixed at + --space-3, so density stays even at any width; screen margins are + --gutter-screen. Components span columns, never fixed pixel + widths — six of twelve is half the page everywhere.

+ +

Content width

+

Pages use one of two standard content widths; the + screen archetype determines which.

+
+ + + +
TokenValuePurpose
--container-contentStandard application screens
--container-narrowReading, forms, focused workflows
+ +

Responsive behaviour

+

Design mobile-first, starting from a 320px viewport. Prefer intrinsic layouts + that adapt to available space through wrapping, flexible sizing, and + content-driven dimensions; introduce a breakpoint only when the overall layout + must change. The system defines two layout breakpoints.

+
+ + + +
TokenValuePurpose
--breakpoint-smNavigation docks; fields may align horizontally; 8-column grid
--breakpoint-lg12-column grid; secondary panels become available
+ +

Shared layout

+

The shared layout owns the application frame — navigation, headers, gutters, + safe-area handling, and fixed chrome. It alone reads the clearance tokens, so + fixed chrome reserves its space exactly once, safe areas included. Pages consume + these values; they never redefine them.

+
+ + + + +
TokenValuePurpose
--side-nav-widthThe fixed navigation rail
--gutter-screenScreen margins — applied by the layout
--header-clearanceReserved space for fixed headers and safe areas, counted once
+ +
    +
  • Build from a 320px viewport up; the page never scrolls sideways.
  • +
  • Prefer intrinsic layouts to breakpoints; add one only when the layout + genuinely changes.
  • +
  • Size content in grid columns, never fixed pixel widths.
  • +
  • Pick a standard container width; never define a custom page width.
  • +
  • Never redefine gutters, clearances, or navigation dimensions inside a + page.
  • +
  • The shared layout owns the application frame; the + screen archetype defines the structure within the + content area.
  • +
+
+ +
+

Foundation

+

Shape

+

Shape establishes visual hierarchy and grouping: a small set of + corner radii keeps components consistent and nested surfaces coherent.

+ +

Corner radius

+

Use the smallest radius that suits the component; larger surfaces generally + use larger radii.

+
+
--radius-1 badges, chips, small elements
+
--radius-2 buttons, inputs, cards (default)
+
--radius-3 dialogs, large panels
+
--radius-round avatars, pills
+
+ +

Nested surfaces

+

Nested surfaces appear as part of one visual system. A child surface never has + a larger corner radius than its parent; the outer radius equals the inner radius + plus the padding between them, so concentric corners stay visually aligned. This + rule applies to all nested containers, inset panels, and elevated surfaces + (Surfaces & elevation).

+
+
outer = inner + padding
concentric nesting
+
+ +

Borders

+

Borders define separation, not decoration. --border-1 is the + default border throughout the interface; --border-2 is reserved for + selected, active, and focused elements. Heavier borders are not an emphasis + tool — create hierarchy through layout, spacing, and typography instead.

+ +
    +
  • Use only the shared radius tokens; larger surfaces generally carry larger + radii.
  • +
  • A nested surface never out-rounds its parent; the concentric formula governs + every nested container and inset.
  • +
  • --border-1 for separation; --border-2 for selection + and focus states only.
  • +
  • Never use a heavier border for emphasis.
  • +
+
+ +
+

Foundation

+

Surfaces & elevation

+

Surfaces establish hierarchy by grouping related content. Every + surface belongs to a defined level that sets its background, shadow, and + stacking order together.

+

Create hierarchy with the simplest visual treatment possible: spacing before + backgrounds, backgrounds before borders, borders before shadows.

+ +

Surface levels

+

The interface uses four surface levels. Cards group content, not other + cards.

+
+ + + + + +
LevelTokensHoldsMay contain
Recessed--muted · no shadowWells, table headers, inset panels, disabled areasFlat content only — nothing raised
Page--background · no shadowThe primary page canvas: text, forms, tablesRecessed insets · cards
Card / panel--card · --shadow-1A self-contained group of related contentFlat content · a recessed inset — never another card
Floating--popover for menus and popovers, --card for dialogs, sheets, toasts · --shadow-2/3 · z-bandMenus, dialogs, popovers, toasts (layers below)Its own content; stacks by layer
+ +

Nesting

+

Nested surfaces communicate structure without adding visual weight. A card + never contains another card; organise content within a card using spacing + first, then a recessed inset, then a border where appropriate. Inset corners + follow the concentric formula (Shape) — a nested surface + never out-rounds its parent.

+
+
+
BillingPlan, seats, renewal date
A recessed inset groups the sub-content
+
Do — one card; spacing or a recessed inset groups within it.
+
+
+
Billing
A card inside the card
+
Don't — a card inside a card adds a border and shadow + without adding hierarchy.
+
+
+

Separation

+

To separate adjacent content, use the first separator in this list that makes the + grouping clear:

+
+ + + + + +
SeparatorReach for it when
1 · WhitespaceDefault. Proximity alone groups — Spacing owns the steps.
2 · Background shiftA zone needs enclosure without a line: recessed insets, table headers.
3 · BorderDense UI where space is already spent: input edges, table wrap, split panes.
4 · DividerRows in tables and dense lists only — never between page sections.
+ +

Floating layers

+

Floating surfaces exist outside the normal page hierarchy. Each layer pairs a + predefined shadow with a z-index, and the two always change together. Floating + surfaces render through the application's overlay system, never inside the page + layout.

+
+ +
Raised → overlay → modal: the deeper the shadow, the higher the + layer.
+
+
+ + + + + + + +
LayerZShadowHolds
ChromeSticky header, bottom nav
Popover--shadow-2Menus, dropdowns
Scrim--scrim behind modals
Modal--shadow-3Dialogs, sheets
Toast--shadow-2Toasts
Tooltip--shadow-1Tooltips
+
    +
  • Every container belongs to a defined surface level, which sets its surface + colour, shadow, and stacking order.
  • +
  • No card inside a card — group within a card using spacing or a recessed + inset, following the concentric corner rule.
  • +
  • Choose the lightest separator that communicates the grouping: whitespace → + background → border; dividers live in tables and dense lists only.
  • +
  • Recessed holds nothing raised; only floating surfaces cast + --shadow-2 and above.
  • +
  • Overlays and fixed chrome render through the shared overlay layer at their + predefined z-index — never where an ancestor's transform can + trap them.
  • +
+
+ +
+

Foundation

+

Motion

+

Motion communicates change: what happened, where content came + from, and what to focus on next. It never exists purely for decoration.

+

Every animation has a clear purpose — communicating state, preserving context, + or guiding attention.

+ +

Duration

+

Three duration bands, chosen by the size of the transition: larger surfaces + move more slowly than smaller interactions. Hover a lane to preview it.

+
+
fast press, toggle, hover
+
base menus, fades, expanding content
+
slow dialogs, sheets
+
+ +

Easing

+

Use the shared easing tokens throughout: --ease-decelerate for + entrances, --ease-standard for every other transition.

+ +

Purpose

+

Motion is used only to communicate state changes, explain where content comes + from or goes, guide attention to important changes, smooth transitions between + interface states, and make waiting and loading feel more natural. Decorative, + looping, and ambient animation is avoided.

+ +
    +
  • Every animation has a purpose.
  • +
  • Choose the duration band by the size of the transitioning surface.
  • +
  • Use the shared easing tokens.
  • +
  • Do not animate static interfaces or idle states.
  • +
  • Prefer subtle transitions over dramatic effects; motion clarifies the + interface, never competes with it.
  • +
+
+ +
+

Foundation

+

Icons

+

Icons aid recognition, never decoration. The system uses one icon + set and four sizes, and an icon always belongs to a label.

+ +

Sizes

+

Four sizes cover every context; choose by where the icon sits, not by taste.

+
+
--icon-size-xs · dense meta, table chips
+
--icon-size-sm · inline with text
+
--icon-size-md · buttons, controls
+
--icon-size-lg · navigation, features
+
+ +

Consistency

+

Each project adopts one icon set, recorded once at adoption; sets are never + mixed — a second set's line weight and style break the familiarity the icons + exist to provide. Use the conventional icon for an action, the one users already + know from established products.

+ +

Icons and labels

+

An icon is part of its label: it inherits the label's colour and centres to the + label's first line. Default to icon plus label. An icon-only control is allowed + only when the metaphor is established and the control carries an + accessible name; otherwise keep the label. Add icons only where they aid + recognition — an icon on every item helps no one.

+ +
    +
  • One icon set per project, recorded once — never mixed.
  • +
  • Use the four size tokens; choose by context.
  • +
  • Icons inherit the label's colour and centre to its first line.
  • +
  • An icon-only control needs an established metaphor and an accessible name; + otherwise keep the label.
  • +
  • Add icons only where they aid recognition.
  • +
+
+ +
+

Foundation

+

States & focus

+

Every interactive element follows the same interaction states, so + users can predict how a control responds regardless of its type.

+

State changes are driven by the design tokens, not by individual components. + A component never defines its own interaction colours or behaviour.

+ +

Interaction states

+

Every interactive element progresses through the same state model, shown here + on an abstract control. Hover and pressed derive automatically from the + underlying colour ramp — one step above rest for hover, two for pressed.

+
+
Rest400
+
Hover500 (+1)
+
Pressed600 (+2)
+
Focusring, visible
+
Selectedsubtle + border
+
Disabledmuted, not hidden
+
+ +

Focus

+

Keyboard focus is always clearly visible: a + ring in + --ring, offset + , applied on + :focus-visible so it appears for keyboard navigation. Never remove + the browser's focus outline unless it is replaced with the system focus + ring.

+ +

Touch feedback

+

Touch interactions always provide immediate visual feedback. Where hover is + unavailable, the pressed state communicates that the interaction has been + recognised.

+ +
    +
  • Every interactive element follows the shared state model.
  • +
  • Hover is one ramp step above rest; pressed is two. No component-specific + hover or pressed colours.
  • +
  • Always show a visible keyboard focus indicator; never remove focus styling + without the equivalent replacement.
  • +
  • Disabled stays visible — muted, not hidden.
  • +
  • Every interaction gives immediate visual feedback.
  • +
+
+ +
+

Foundation

+

Accessibility

+

Every screen meets one consistent baseline. Most of it is built + into the system through the shared tokens and components.

+
    +
  • Semantic colour pairings meet WCAG AA contrast requirements.
  • +
  • Interactive elements show a visible keyboard focus indicator.
  • +
  • Respect the user's reduced-motion preference — the duration tokens collapse + to zero automatically, and essential feedback never rides on motion alone.
  • +
  • Layouts remain usable from a 320px viewport and at 200% browser zoom.
  • +
  • Touch targets are at least + .
  • +
  • Never rely on colour alone to communicate meaning.
  • +
  • Headings establish a logical document structure.
  • +
+

Detailed accessibility guidance and implementation requirements — ARIA, + keyboard behaviour, screen-reader support — live in + design/README.md.

+
+ +
+

Foundation

+

Content

+

Interface copy helps users understand, decide, or act. Write only + what users need to complete their task.

+

Prioritise, in order: accuracy, clarity, relevance, concision, tone. Never + sacrifice meaning for brevity.

+ +

Writing principles

+

Write in plain, direct English. Avoid promotional, conversational, or overly + enthusiastic language.

+
    +
  • Sentence case everywhere; actions verb-first — "Save changes", never + "OK".
  • +
  • Short, complete sentences; active voice unless passive is clearer; one idea + per sentence.
  • +
  • Lead with the information users need first, and group related information + together.
  • +
  • Specific nouns and verbs; state observable actions and outcomes, not vague + claims.
  • +
  • Use established product and domain terminology; define unfamiliar terms + only when necessary.
  • +
  • Remove words that do not change the meaning.
  • +
  • Never blame the user.
  • +
+ +

Common patterns

+

Each surface has a specific purpose.

+
+ + + + + +
SurfaceThe copy's job
EmptyWhy it's empty + the next step: "No invoices yet. Create your first invoice."
ErrorWhat happened + how to recover, with the reference id; always a next step.
SuccessConfirm and name the object: "Invoice sent to Acme."
DestructiveName the consequence and count before confirmation: "Delete 3 invoices? This can't be undone."
+
+
+
+ + No invoices yet + Create your first invoice to start getting paid. + Create invoice +
+
Do — an empty state explains and offers the next step.
+
+
+
No data.
+
Don't — no explanation and no next step.
+
+
+ +

Action labels

+

One verb for one action throughout the application; an action never ships + under two labels.

+
+ + + + + + + + + + + +
VerbUse forNot
CreateMaking a new record — "Create invoice"New, Add
AddAttaching something that exists — "Add member"Create
EditOpening a record for changeModify, Change
SaveCommitting changes — "Save changes"Update, Submit (Submit = send for processing)
DeleteDestroying data — confirmed, per the row aboveRemove
RemoveDetaching without destroying — "Remove member"Delete
SearchFree-text lookup across recordsFind
FilterConstraining the visible listRefine
ExportProducing a file from dataDownload (Download = fetch an existing file)
CancelLeaving without savingClose (Close = dismiss, nothing pending)
+ +

What to avoid

+
    +
  • Filler, unnecessary words, and explanations of obvious behaviour.
  • +
  • Invented terminology when established terms exist.
  • +
  • Marketing language, exaggerated claims, rhetorical questions, and + slogans.
  • +
  • Artificial contrasts for emphasis, and the same point repeated in + different words.
  • +
  • Introductions or conclusions that do not help the user.
  • +
+

Prefer instructions and outcomes over explanations.

+
+ + + +
Instead ofWrite
The system handles the issue automatically.The system retries the request up to three times.
The setting controls visibility, not access.The setting controls whether the item is visible. It does not change who can access it.
+ +

Content management

+

Store all user-facing copy in the application's localisation or string + resources — reviewable as one surface. No hard-coded text in components.

+ +
    +
  • Write to help users understand, decide, or act; prioritise accuracy over + brevity.
  • +
  • Plain, direct English; sentence case; established terminology.
  • +
  • One action, one verb.
  • +
  • Empty states explain and guide; errors explain what happened and how to + recover; success messages confirm the completed action.
  • +
  • Never blame the user.
  • +
  • All interface copy lives in localisation resources.
  • +
+
+ +
+

Foundation

+

Data formatting

+

Users never relearn how dates, numbers, or currencies are + displayed from one screen to another: the same value always appears in the + same format.

+

Each data type has one format, implemented through the platform's formatting + libraries rather than custom logic. Store data in its canonical form and format + it only when presented to users.

+ +

Standard formats

+
+ + + + + + + + +
DataRuleExample
DatesMonth name in prose; ISO 8601 when numericJan 12, 2026 · 2026-01-12
Relative timeRelative for recent events, then absoluteJust now · 13 min ago · Yesterday at 10:30 am · Jan 12
NumbersThousands separators; tabular figures when values are compared vertically12,480
CurrencySymbol day-to-day; ISO code where currencies mix — one or the other, never both$12.50 · SGD 12.50 · −$4.20
Empty valueEm dash — never blank, "null", or a fake 0
TextWraps by default; truncates only in fixed-width slots with the full value accessibleQuarterly Financial Rep…
IdentifiersAtomic — never wrap, split, or truncateINV-2041
+ +

Dates and time

+

Store timestamps in UTC. Render dates, times, time zones, and locales through + the shared formatting utilities, pinned in one place, so every screen presents + them identically.

+ +
    +
  • Use the shared formatting utilities for every data type; no custom + formatting.
  • +
  • One format per data type throughout the application.
  • +
  • Tabular figures when comparing numeric values; an em dash for empty + values.
  • +
  • Wrap free text by default; never split identifiers, codes, or other atomic + values.
  • +
  • Store timestamps in UTC and format them in the user's locale.
  • +
+
+ +
+

Composition

+

Screen archetypes

+

Most enterprise applications consist of a small number of + recurring page layouts. Start from one of these archetypes before designing a + new screen.

+

Consistent page structures reduce the learning curve for users, speed up + development, and keep the product visually coherent. A page has one primary + archetype; individual sections may reuse patterns from another archetype (a + dashboard widget shows a small table), but the overall page structure stays + consistent. If no archetype fits, extend this guide before creating a new page + layout.

+ +

Application frame

+

Every page lives within the shared application layout + (Layout), which owns the header, the navigation, the + responsive behaviour, the gutters, and the content width. The archetype defines + only the content area. Below sm the nav rail collapses into a + menu — or a bottom bar on a mobile-first project — and the same page fills the + same region unchanged.

+
+
+
+ +

sm: fixed side nav

+
+
+ +

< sm: nav collapses (menu, or bottom nav on mobile-first)

+
+
+
1 top bar (--header-height) · + 2 side nav (--side-nav-width, fixed width) · + 3 the page — the archetype's territory.
+
+ +

Shared page structure

+

Every page begins the same way: a page title (the page's only H1), an optional + description, an optional breadcrumb or back action, page-level actions on the + trailing edge, then the page content.

+
+ +
Title left, page actions trailing, content below. Spacing and widths + come from the layout and the page-rhythm tokens + (Spacing · Layout).
+
+ +

Table / grid

+

Browse, search, filter, and act on many records — customers, orders, users, + invoices. Use when the primary task is finding or managing many items.

+
+ +
Zones: page header → search and filters → table or card list + (Tables & grids) → pagination. Reflow: rows become + cards below sm; the toolbar wraps; row actions fold into a + menu.
+
+ +

Record detail

+

Everything about a single record — a customer profile, an invoice, an employee + record. Use when understanding one record is the primary task.

+
+ +
Zones: page header (record name · status · actions) → key-facts + strip → content sections → related records and activity last. Reflow: the + facts strip wraps; side-by-side sections stack below sm.
+
+ +

Form

+

Create or edit a record on a single page — create customer, edit invoice, + update profile. Use when users can complete the task comfortably in one + sitting.

+
+ +
Zones: page header → field groups (one column) → primary actions. + Field anatomy, widths, and validation: the Forms pattern. + Reflow: one column at every width; only short, related fields pair up from + sm.
+
+ +

Multi-step flow

+

Guide users through a longer or unfamiliar process — onboarding, a loan + application, checkout, product setup. Use when the task has several logical + stages or would otherwise overwhelm users.

+

The progress stepper is horizontal, centred at the top of the content, above + the step. A stepper implies sequence — a long page that merely has many + sections is not a multi-step flow; give it vertical section tabs that work as + a minimap, never a stepper.

+
+ +
Zones: page header → horizontal progress stepper, centred → one + topic per step → step actions (previous · continue) → a review step before + completion. Reflow: the stepper compresses to "Step 2 of 4" below + sm.
+
+ +

Dashboard

+

An overview of system status that directs users to the operational pages — + executive, sales, operations. A dashboard summarises and links to where the + work happens; it never becomes the workplace itself.

+
+ +
Zones: page header (+ scope/time filter) → KPI strip → charts, + recent activity, alerts, and shortcuts on the grid. + Each widget hosts another archetype's content pattern in miniature and links + to it. Reflow: 12 → 8 → 4 columns; the KPI strip wraps 4 → 2 → 1.
+
+ +

Settings

+

View and modify configuration — profile, security, billing, notifications, + team. Group related settings; a large settings area earns vertical tabs — a + minimap of the groups — never a long scrolling page.

+
+ +
Zones: page header → settings groups — one group, one surface; + label-left, control-right rows inside. Save behaviour: the + Forms pattern. Reflow: label/control rows stack below + sm.
+
+ +

Utility

+

Standalone pages outside the application shell — sign in, sign up, password + reset, verify email, invitation acceptance, first-run onboarding. These pages + omit the application navigation and focus on one task.

+
+ +
Zones: one centred column — logo → headline → supporting text → + primary action. No nav, no chrome. Copy rules: + Content.
+
+ +

Error & status

+

Communicate that something prevented the requested action and guide the user + to recovery — not found, forbidden, server error, maintenance, offline, session + expired. Every error page explains what happened and offers a meaningful next + step.

+
+ +
Zones: one centred column — status icon or illustration → clear + heading → short explanation → recovery action → optional secondary + action.
+
+ +

View states

+

Every archetype defines its common states — loading, empty, populated, error, + partial failure, permission denied. A view state replaces the content within + the page; it never changes the page's archetype. The full patterns: + View states & feedback.

+ +

Choosing an archetype

+
+ + + + + + + + + +
Primary user goalArchetypeNot for → use insteadWidth
Browse many recordsTable / gridReading one record → Record detailcontent
Understand one recordRecord detailHeavy editing → Formcontent
Create or edit dataFormLong or unfamiliar tasks → Multi-step flownarrow
Complete a guided workflowMulti-step flowTasks that fit one screen → Formnarrow
Monitor the systemDashboardActing on records → Table / gridcontent
Configure behaviourSettingsOne-shot data entry → Formcontent
Complete a standalone taskUtilityAnything with navigation → the othersnarrow
Recover from an errorError & statusA zone-level failure → the page's error view statenarrow
+ +

Density

+

The box scale (Spacing) sets the app's density: a denser + product remaps --box-padding-* once, at the token layer. A zone's box size is + part of its archetype — table rows sit in box-sm by definition, set by + the archetype rather than per page.

+ +
    +
  • Start every page from an existing archetype; one primary archetype per + page.
  • +
  • The shared layout owns the application frame; the archetype owns the page + structure within it.
  • +
  • Sections may reuse another archetype's content pattern; the overall page + keeps one structure.
  • +
  • Page rhythm, edges, and widths come from the tokens and the layout — never + re-derived per page.
  • +
  • View states replace content, never the layout.
  • +
  • A sequence gets a horizontal, centred progress stepper; a long page gets + vertical section tabs (a minimap) — never a stepper.
  • +
  • No fit → extend this set in the guide before inventing a new page + layout.
  • +
  • One density app-wide, set at the token layer — never mixed within a + page.
  • +
+
+ +
+

Composition

+

Tables & grids

+

Tables are the primary workspace in enterprise applications. + Default to the standard table pattern whenever users work with collections of + data.

+

Search, sorting, filtering, pagination, column management, and bulk actions + are part of the standard experience — present by default, never added later as + requirements emerge. This pattern implements the + Table / grid archetype: build it with the project's + shared data table component, based on the adopted component library and table + engine, and extend the shared component through the + reuse order rather than creating feature-specific + implementations.

+
+

Optimise for repeated use, not first use. Enterprise users + work in the same tables every day, so consistency, efficiency, and + discoverability outrank a minimal first impression. A capability experienced + users rely on is part of the standard table, never an optional enhancement — + which is why these defaults are richer than a consumer application's.

+
+ +

Anatomy

+
+
+
+ Search invoices… + Issued: Q2 2026 + Clear all + Columns ▾Export +
+
+ + + + + + +
InvoiceCustomerStatusIssued ↓Amount
INV-2041Acme CorpOverdueJun 28, 202612,480.00
INV-2040NorthwindPaidJun 21, 20263,150.00
INV-2036GlobexPendingMay 30, 2026890.50
INV-2029InitechPaidMay 12, 202624,000.00
+
+
+ Rows: 25 ▾ + 1–25 of 37 · filtered from 312 + +
+
+
Zones, top to bottom: toolbar (global search leading · + active filters as chips · view controls trailing) → header row + (recessed, sortable, sticky) → rows in box-sm → + footer (page size · count · pages). The footer count keeps the + unfiltered total visible: a filtered table reads filtered from 312, + never just "37".
+
+ +

The toolbar order is fixed: search, then active filter chips, then view + controls (Columns, Export). The page's primary action belongs in the page + header, never inside the toolbar.

+ +

Standard capabilities

+

Every capability below is on by default for a working table. + Removing one (pagination on a bounded ten-row list, selection where no bulk + action exists) is a deliberate product decision, and it is recorded.

+
+ + + + + + + + +
CapabilityThe norm
Global searchOne box, leading in the toolbar; matches as you type across every searchable column; behaves as a filter and clears like one.
SortingEvery column sorts unless order is meaningless; one column at a time; the active header shows its direction and announces aria-sort; every table declares a default sort.
Column filtersEach column filters from its own header menu — free text within the column, or pick-from-values; active criteria surface as toolbar chips, removable one by one or via Clear all.
PaginationNumbered pages with an "x–y of z" count and a page-size picker — 10 / 25 / 50 / 100, default 25. Work tables never infinite-scroll.
Column customisationA Columns control toggles visibility and order; the identity column can't be hidden; choices persist per user, per table.
Selection & bulkLeading checkbox column; the header checkbox selects the page and offers "select all z"; a bulk bar with the count replaces the toolbar while a selection exists.
ExportExports the current dataset or the current selection, from the view controls.
+ +

Bulk actions

+
+
+
+ 2 selected + Select all 312 + ExportDelete +
+
+ + + + +
INV-2041Acme Corp12,480.00
INV-2040Northwind3,150.00
INV-2036Globex890.50
+
+
+
While a selection exists, a bulk bar replaces the toolbar. It shows the + selected count, its actions apply to exactly that count, and ✕ clears the selection. + Destructive bulk actions confirm with the count (Content).
+
+ +

Column conventions

+
+ + + + + + + + + +
ContentConvention
Identity (name, number)First after selection; links to the record; can't be hidden; one line, truncates with the full value reachable
TextLeft-aligned, one line
NumbersRight-aligned, tabular numerals, one precision per column (Data formatting)
CurrencyRight-aligned, the shared currency formatter (Data formatting)
DatesLeft-aligned, one format per table (Data formatting)
StatusA badge on the intent formula — text + tint, never colour alone (Colour)
IDs & codesMono, atomic — never truncated or wrapped
ActionsTrailing edge; at most two visible, the rest in a ⋯ menu
+
+
+
Amount
12,480.00
890.50
+
Do — amounts right-aligned in tabular numerals, one precision: magnitudes compare at a glance.
+
+
+
Amount
12480
890.5
+
Don't — left-aligned with mixed precision; each comparison requires re-reading the values.
+
+
+

Row behaviour

+

The row is the primary interaction target: clicking it opens the record, and + the trailing actions column carries the secondary tasks. One row height per + table (box-sm padding, font-size-200, tight + line-height) — cell content truncates with the full value reachable, and the + row never grows. The header is recessed and sticks while the table scrolls; + selection and identity columns stay pinned when it scrolls horizontally.

+ +

Filter state

+

Filtering always remains visible: active filter chips, a Clear all action, and + a count that keeps the unfiltered total on screen — "1–25 of 37 · filtered from + 312". Never show only the filtered count; users must always see that more + records exist.

+
+
+
Status: Overdue 1–8 of 8 · filtered from 312
+
Do — the filter is visible: a removable chip and a count that shows the unfiltered total.
+
+
+
8 items
+
Don't — with no filter indicator the table looks complete; the 304 hidden rows are invisible.
+
+
+ +

Responsive behaviour

+

Wide tables scroll horizontally inside their own container; the page itself + never scrolls sideways (Layout). Below sm + the table becomes cards — identity, one or two key attributes, status, and the + primary action. Never compress a desktop table into a narrow layout.

+ +

Loading and empty states

+

Tables use the standard view states. Loading + renders skeleton rows that preserve the table layout. Empty distinguishes "no + records yet" — with a primary action to create one — from "no matching + results", with guidance to clear or adjust filters. Errors follow the standard + error pattern.

+ +

Performance

+

Past one page of data, search, sorting, filtering, and pagination execute + together on the server. Never mix client-side and server-side behaviour — a + search that sees only the loaded page is broken.

+ +

User preferences

+

Visible columns, column order, sort order, and page size persist per user, per + table. Users configure a table once, never repeatedly.

+ +
    +
  • Default to the standard table pattern; search, sorting, filtering, + pagination, column management, bulk actions, and export are present unless + their removal is a recorded decision.
  • +
  • Toolbar order fixed: search → filter chips → view controls. The page's + primary action stays in the page header.
  • +
  • The row is the primary target; one row height per table; row dividers are + the sanctioned divider use (Surfaces).
  • +
  • Columns follow the shared formatting conventions + (Data formatting).
  • +
  • Filter state and the unfiltered total are always visible.
  • +
  • Search, sort, filter, and pagination run server-side together.
  • +
  • Preferences persist per user, per table.
  • +
  • Below sm the table becomes cards — never a compressed + grid.
  • +
+
+ +
+

Composition

+

Forms

+

Forms are the primary pattern for creating and editing data. They + minimise cognitive load through a clear, predictable structure.

+

Use the project's shared form components rather than composing fields directly + from HTML elements. Configure the component library to follow this standard, + and extend the shared components through the + reuse order instead of creating feature-specific + variations.

+
+

Optimise forms for data entry, not visual symmetry. Field + widths match the expected input, not the grid — not every field is full width. + Dense forms are acceptable while they stay readable; reduce scrolling before + adding decorative whitespace. Short related pairs — first and last name, postal + code and country — share a row instead of each spanning the page.

+
+ +

Structure

+

Every form follows the same layout: page header → field groups → actions. Each + field group holds its fields in one column; only short, closely related fields + share a row. Fields sit --space-3 apart, groups + --page-section-gap apart (Form + archetype). Avoid complex multi-column forms unless there is a clear + usability benefit.

+ +

Field anatomy

+

Every field follows the same structure: label, input, helper text. Labels are + always visible and sit above the field. Helper text sits below the input and + explains; the validation message replaces it when present. Placeholders give + examples only — never labels.

+
+
+
+ Company name + + As it appears on the invoice. +
+
Do — label above, helper below; the field is its own row.
+
+
+
+ Company name +
+
Don't — a placeholder as the label: it vanishes on focus + and fails as a label.
+
+
+

Field width

+
+
+
+ Postal code + +
+
Do — width mirrors the expected answer.
+
+
+
+ Postal code + +
+
Don't — a six-character answer in a paragraph-wide field.
+
+
+ +

Validation

+

Validate as early as practical without interrupting the user's flow. When + validation fails: the error appears directly beneath the affected field, + replacing the helper text; it explains what is wrong and how to fix it; and on + submission, focus moves to the first invalid field. Errors help users recover — + they never merely state that validation failed.

+
+
+ Email + + Enter an email with an @ — like name@company.com. +
+
The error replaces the helper, under the field it describes — what happened + plus what to do (Content).
+
+ +

Required and optional fields

+

Fields are required unless marked otherwise. Mark optional fields + "(optional)"; never use asterisks to indicate required fields.

+ +

Actions

+

Actions close the form: the primary action on the trailing edge, secondary + actions beside it, destructive actions kept separate on the leading edge. The + same order applies to pages, dialogs, and inline forms.

+ +

Choosing the right control

+

Use the simplest control that accurately captures the data. Avoid custom + controls when a standard component communicates the intent more clearly.

+
+ + + + + + + + + +
DataControl
Short textText input
Long textTextarea
One option from a small setRadio group
One option from a large setSelect, or a searchable combobox
Multiple optionsCheckbox group
BooleanSwitch
DateDate picker
NumberNumber input
+ +

Progressive disclosure

+

Show only the fields the current task requires; reveal additional fields when + earlier input or the workflow makes them relevant. Large forms divide into + logical sections. A long or unfamiliar workflow uses the + Multi-step flow archetype, never a single very long + page.

+ +
    +
  • Use the shared form components; standard controls before custom ones.
  • +
  • Labels always visible, above the field; the placeholder is never the + label.
  • +
  • Single column by default; only short, related fields share a row (from + sm); field width reflects the expected input.
  • +
  • Helper text explains; validation replaces it, names the fix, and focus + moves to the first failure on submit.
  • +
  • Mark optional fields "(optional)" — nothing is asterisked.
  • +
  • Actions close the form: primary trailing, secondary beside, destructive + apart on the leading edge — the same order everywhere.
  • +
  • Split long workflows into steps instead of building very long forms.
  • +
  • The pattern outranks the library default: configure the library to match; + what it can't express is justified via the + reuse order.
  • +
+
+ +
+

Composition

+

View states & feedback

+

Every page and component communicates its current state clearly. + A state replaces only the content it describes; feedback appears as close as + possible to what caused it.

+

This chapter decides placement; the wording is covered in + Content.

+
+

Preserve context whenever possible. Never wipe the page + during a refresh, replace the whole screen because one zone is loading, or + show a full-screen spinner for a partial update. Stale data stays visible + while newer data loads, and errors appear only where they occurred.

+
+ +

View states

+

The same view states apply throughout the application. A loading or error + state replaces only the affected content, never the entire page.

+
+ + + + + + +
StateWhere it rendersBehaviour
LoadingIn the zone it describesSkeletons matching the final layout
RefreshingStale content stays putSubtle indicator on the zone or control — never a page wipe
EmptyIn the zone, where records would beWhy it's empty + the next step
ErrorIn the zone that failedWhat failed + a recovery action
PartialLoaded zones renderSkeletons or retries only where data is still due
+
+
+
+
Do — the records zone skeletons; header and toolbar + stay interactive.
+
+
+
+
Don't — one zone's wait wipes the whole page behind + a spinner.
+
+
+ +

Loading

+

Skeletons preserve layout and reduce perceived waiting: simple geometric + shapes matching the size and structure of the final content, so the swap causes + no layout shift, pulsing subtly on the shared motion tokens. No full-page + spinners, no shimmer sweeps, no placeholder text. If the layout is known, use + skeletons; if only an individual action is pending, show progress on the + control itself instead.

+
+ +
One zone's skeleton: an identity row (avatar + two lines), a media block, + three text lines. Each box mirrors the element it holds space for — nothing more.
+
+ +

Empty states

+

An empty state explains why nothing is shown and what the user can do next. + Differentiate three situations, each guiding its own next action: no data + exists yet (offer the create action), filters returned no results (offer to + clear or adjust them), and the user lacks permission (say so, and name who to + ask).

+ +

Refreshing

+

Refreshing preserves context: existing content stays visible under a subtle + indicator while newer data loads. Never replace populated content with a + loading screen during a refresh.

+ +

Feedback

+

Choose the lightest feedback mechanism that clearly communicates the outcome, + and show it as close as possible to the cause.

+
+ + + + + +
SituationPattern
Field validation, control feedback — validation, toggle, pressInline, at the control that caused it
Background action completed or failed — invoice sent, settings saved, export startedToast — transient, non-blocking, names the object
Confirmation required — delete records, discard changes, replace dataDialog — names the consequence and count before the user proceeds
System-wide condition — offline, subscription expired, maintenance, missing permissionsBanner atop the affected zone or page, visible until resolved or dismissed
+

Toasts confirm, never interrupt. Dialogs interrupt only when a decision blocks + the action. Banners persist while their condition holds.

+ +
    +
  • Replace only the affected content, never the entire page; the surrounding + layout stays visible.
  • +
  • Skeletons for known layouts, progress on the control for a pending action — + never both for one wait, never a full-page spinner.
  • +
  • Refresh keeps stale content visible under a subtle indicator — no wipes, + no layout jumps.
  • +
  • Empty states explain what happened and what to do next.
  • +
  • Route feedback by cause — inline → toast → dialog → banner, the lightest + that fits, always on or naming what caused it.
  • +
  • Dialogs interrupt only when user confirmation is required.
  • +
  • Every state's copy follows the Content table — + placement here, words there.
  • +
+
+ +
+

Beyond

+

Components

+

Applications are built by composing reusable components. Screens + never build UI directly from HTML elements or duplicate existing components.

+

Components own appearance, interaction, accessibility, and behaviour; screens + compose them. Components consume the design system through semantic tokens, + never hard-coded values.

+
+

Never style HTML elements directly in application code. + HTML elements are implementation details of the component library, not building + blocks for screens: <Button />, never + <button className="…">.

+
+ +

Component hierarchy

+
+
+
tokensNamed design decisions — colour, type, spacing, motion (Tokens).
+
+
atomsThe smallest reusable components.
+
+
moleculesAtoms combined into reusable interactions.
+
+
organismsFeature-specific sections composed of atoms and molecules.
+
+
archetypesOrganisms arranged into a page structure (Screen archetypes).
+
+
pagesAn archetype filled with the feature's content.
+
+
Each layer is built from the one above it.
+
+

Atoms — Button, Input, Checkbox, Switch, Badge, Avatar, Icon, + Spinner — own their styling, states, accessibility, and interaction behaviour. + Screens use these shared components rather than styling HTML elements.

+

Molecules — Form field, Search input, Empty state, Pagination, + Date picker, Command palette — are complete interactions that appear in multiple + places.

+

Organisms — Billing summary, User profile, Navigation sidebar, + Data table, Activity feed — belong to the application, not the design + system.

+ +

Reuse order

+

Before creating anything new, stop at the first option that fits. "It would be + cleaner to rewrite it" is not a valid reason to create another component; a + near-duplicate with different styling fails the bar — restyle or extend the + original instead.

+

The reuse order applies to compositions as well as component types. Before + arranging existing atoms in a new way, search for the same action or interaction + in a comparable context and reuse its composition.

+
+
+
1 · archetypeUse an existing screen archetype.
+
no fit ↓
+
2 · patternUse a documented design pattern — tables, forms, view states.
+
no fit ↓
+
3 · organismUse an existing organism.
+
no fit ↓
+
4 · moleculeUse an existing molecule.
+
no fit ↓
+
5 · atomUse an existing atom.
+
no fit ↓
+
6 · extendExtend an existing component with a variant or option.
+
no fit ↓
+
7 · generateGenerate from the project's component library and theme it.
+
no fit ↓
+
8 · newBuild a new shared component — and record why nothing above fit.
+
+
The reuse order: stop at the first option that fits.
+
+ +
    +
  • Build screens from shared components, not raw HTML elements; never style + native HTML elements directly in application code.
  • +
  • Use semantic design tokens only.
  • +
  • Search before creating; extend before duplicating.
  • +
  • Promote repeated components into the shared library.
  • +
  • Screens compose components; components own behaviour.
  • +
+
+ + +
+
+ + + + diff --git a/design/tokens.css b/design/tokens.css new file mode 100644 index 0000000..4da1231 --- /dev/null +++ b/design/tokens.css @@ -0,0 +1,300 @@ +/* + KEYSTONE — design tokens, the single token source. + (see design/README.md — the shell derives its roles from these) + + Three tiers: + 1. PRIMITIVE — raw scales. Rebrand HERE; everything re-derives. + 2. SEMANTIC — role-named aliases. Components use THESE, nothing deeper. + Colour roles follow shadcn/ui's theme vocabulary (--background, + --primary, --muted-foreground …) so copy-in components consume the + project's tokens without renaming. Roles shadcn doesn't define + (intent ramps, scrim, link, disabled/inverse text) extend the set + in the same naming style. + 3. COMPONENT — per-component overrides, only where genuinely needed. + + Default palette: the Cavalry brand (cavalry.sg) — Cavalry red primary, + purple-cast neutrals, violet/green/amber support; Space Grotesk display + over Inter body. Swap the primitive tier for another brand's ramps. + + Deliberately small: 4–6 steps per colour family, 6 type sizes, 6 space steps. + If a screen needs a value that isn't here, extend the scale — never the screen. + + One light theme. Dark is a later remap of the SEMANTIC tier only, if a + project needs it. Intent formula: subtle bg = 50 · border = 200 · + interactive/content = 400 (holds ≥ 4.5:1 on white — verified) · + text-on-tint = 600. The interactive red adds hover = 500 · pressed = 600; + support hues carry no hover duty, so four steps is their whole ramp. + Brand-vivid values that can't hold 4.5:1 (pure Cavalry red, amber) sit at + 300/400 for graphics and large accents only, never body-size text on white. + + Note: --primary and --destructive share the brand-red family in this + palette — destructive actions are disambiguated by wording and + confirmation, not hue. And --accent is shadcn's accent: the quiet + hover/selected surface, never the CTA colour — the CTA is --primary. + + Breakpoints can't be custom properties in media queries — keep in sync by + convention: sm 640 · lg 1024. Author from the smallest width up. +*/ + +:root { + color-scheme: light; + + /* ════════════════ 1 · PRIMITIVE ════════════════ */ + + /* Colour — purple-cast neutral, Cavalry red, violet / green / amber */ + --white: #ffffff; + + --gray-50: #f6f4f8; + --gray-100: #efecf4; /* the ground a window sits on — see --ground */ + --gray-200: #e6e2ec; + --gray-300: #cfc9d8; + --gray-400: #aaa2b3; + --gray-600: #665e72; + --gray-700: #453f52; /* secondary text: darker than muted, lighter than ink */ + --gray-900: #171320; + + --red-50: #fff1f0; + --red-200: #ffb3ad; + --red-300: #ff3b30; /* the brand red — graphics & large accents (3.5:1 on white) */ + --red-400: #e02b20; /* interactive Cavalry red — 4.6:1 on white */ + --red-500: #b8231a; + --red-600: #8f1b14; + + --violet-50: #f4effe; + --violet-200: #c9b0f8; + --violet-400: #7c3aed; /* brand-exact */ + --violet-600: #4f1fa3; + + --green-50: #e9f9f2; + --green-200: #7fe0bd; + --green-400: #0b8157; + --green-600: #085c41; + + --amber-50: #fef5e6; + --amber-200: #fbd38d; + --amber-400: #f59e0b; /* brand-exact — graphics only (2.2:1 on white) */ + --amber-600: #8a5c06; + + /* Type — Space Grotesk for titles/headings, Inter for everything else; + both fall back to the system stack when not loaded */ + --font-family-base: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Helvetica, Arial, sans-serif; + --font-family-display: "Space Grotesk", var(--font-family-base); + --font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + + --font-size-100: 0.75rem; /* 12 — caption, overline */ + --font-size-200: 0.875rem; /* 14 — labels, tables, dense UI */ + --font-size-300: 1rem; /* 16 — body (the default) */ + --font-size-400: 1.25rem; /* 20 — headings */ + --font-size-500: 1.75rem; /* 28 — page title */ + --font-size-600: 2.5rem; /* 40 — display: covers, heroes */ + + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-bold: 700; + + --line-height-tight: 1.25; /* headings, controls */ + --line-height-base: 1.5; /* body copy */ + --tracking-tight: -0.02em; /* titles */ + --tracking-caps: 0.06em; /* uppercase overlines */ + /* Tabular digits in tables/metrics: font-variant-numeric: tabular-nums */ + + /* Space — 6 steps. Every margin, padding, and gap is one of these. */ + --space-1: 0.25rem; /* 4 — inside controls */ + --space-2: 0.5rem; /* 8 — icon↔label, tight groups */ + --space-3: 1rem; /* 16 — component padding, gaps */ + --space-4: 1.5rem; /* 24 — between components */ + --space-5: 2rem; /* 32 — between sections */ + --space-6: 4rem; /* 64 — page-level rhythm */ + + /* Shape — concentric nesting: outer radius = inner radius + padding */ + --radius-1: 0.25rem; /* 4 — badges, chips, checkboxes */ + --radius-2: 0.5rem; /* 8 — buttons, inputs, cards */ + --radius-3: 0.75rem; /* 12 — modals, sheets, panels */ + --radius-round: 9999px; + + --border-1: 1px; /* default */ + --border-2: 2px; /* selected, focus */ + + /* Elevation — shadow and z-band travel together */ + --shadow-1: 0 1px 2px rgba(23, 19, 32, 0.08); /* raised: cards */ + --shadow-2: 0 4px 16px rgba(23, 19, 32, 0.12); /* overlay: menus, popovers */ + --shadow-3: 0 16px 48px rgba(23, 19, 32, 0.22); /* modal: dialogs, sheets */ + + --z-chrome: 100; /* sticky header / bottom nav */ + --z-popover: 200; /* menus, dropdowns */ + --z-overlay: 300; /* scrims, sheets */ + --z-modal: 400; /* dialogs */ + --z-toast: 500; /* toasts */ + --z-tooltip: 600; /* above everything */ + + /* Motion — reduced motion collapses all to 0 */ + --duration-fast: 150ms; /* state feedback: press, toggle */ + --duration-base: 250ms; /* reveals: menu, tooltip, fade */ + --duration-slow: 500ms; /* large surfaces: sheet, modal */ + --duration-pulse: 2000ms; /* skeleton pulse cycle */ + --ease-standard: cubic-bezier(0.2, 0, 0, 1); /* default (exits too) */ + --ease-decelerate: cubic-bezier(0, 0, 0.2, 1); /* entrances */ + + /* Focus — one visible ring everywhere */ + --focus-ring-width: 2px; + --focus-ring-offset: 2px; + + /* Sizing */ + --icon-size-xs: 0.75rem; /* 12 — dense meta, table chips */ + --icon-size-sm: 1rem; /* 16 — inline with text */ + --icon-size-md: 1.25rem; /* 20 — buttons, controls */ + --icon-size-lg: 1.5rem; /* 24 — navigation, features */ + --touch-target-min: 2.75rem; /* 44 — touch form factors */ + + /* Layout */ + --breakpoint-sm: 40rem; /* 640 — informational copy */ + --breakpoint-lg: 64rem; /* 1024 — informational copy */ + --container-content: 72rem; /* standard pages */ + --container-narrow: 42rem; /* reading, focused flows */ + --measure: 65ch; /* body copy cap */ + + /* ════════════════ 2 · SEMANTIC ════════════════ */ + + /* The ground a window or card sits on. shadcn has no role for it because it + assumes a page, and every Visual Stack page frames something. */ + --ground: var(--gray-100); + --background: var(--white); + --foreground: var(--gray-900); + --foreground-2: var(--gray-700); /* secondary text, still body-legible */ + --card: var(--white); + --card-foreground: var(--gray-900); + --popover: var(--white); + --popover-foreground: var(--gray-900); + --scrim: rgba(23, 19, 32, 0.4); + + --muted: var(--gray-50); /* quiet surface: subtle bg, chrome */ + --muted-foreground: var(--gray-600); + --foreground-disabled: var(--gray-400); + --foreground-inverse: var(--white); /* text on a dark (foreground) surface */ + + --border: var(--gray-200); + --border-strong: var(--gray-300); + --input: var(--gray-300); /* form-control border */ + --ring: var(--red-400); + + /* Primary — the single primary action per view; links share the hue */ + --primary: var(--red-400); + --primary-hover: var(--red-500); + --primary-active: var(--red-600); + --primary-subtle: var(--red-50); + --primary-foreground: var(--white); + --link: var(--red-400); + + /* Secondary & accent — quiet interactive surfaces (accent = the + hover/selected surface in shadcn's vocabulary, never the CTA) */ + --secondary: var(--gray-50); + --secondary-foreground: var(--gray-900); + --accent: var(--gray-50); + --accent-foreground: var(--gray-900); + + /* Intent — never colour alone; pair with text or an icon */ + --success: var(--green-400); + --success-strong: var(--green-600); /* text on the subtle tint — formula step 600 */ + --success-subtle: var(--green-50); + --success-border: var(--green-200); + --warning: var(--amber-600); + --warning-subtle: var(--amber-50); + --warning-border: var(--amber-200); + --destructive: var(--red-400); + --destructive-strong: var(--red-600); /* text on the subtle tint — formula step 600 */ + --destructive-subtle: var(--red-50); + --destructive-border: var(--red-200); + --primary-border: var(--red-200); /* the tint's edge, paired with --primary-subtle */ + --destructive-foreground: var(--white); + --info: var(--violet-400); + --info-subtle: var(--violet-50); + --info-border: var(--violet-200); + + --radius: var(--radius-2); /* shadcn's base radius alias */ + + --text-body: var(--font-size-300); + --text-caption: var(--font-size-100); + + /* The shared layout applies these; pages never re-derive them */ + --gutter-screen: var(--space-3); + --side-nav-width: 9rem; /* fixed nav rail from sm up; collapses below */ + --header-height: 3.5rem; + --header-clearance: calc(var(--header-height) + env(safe-area-inset-top, 0px)); + --bottom-nav-height: 3.5rem; /* mobile-first form factors */ + --bottom-nav-clearance: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px)); + + /* Page rhythm — fixed by the screen archetype (design-guide.html → + Screen archetypes); pages never re-decide these gaps */ + --page-title-gap: var(--space-4); /* page-header block → first content */ + --page-section-gap: var(--space-5); /* between content sections in a page */ + + /* ════════════════ 3 · COMPONENT ════════════════ */ + + --control-height: 2.25rem; /* 36 — shadcn's default control size (h-9) */ + --control-height-sm: 2rem; /* 32 — dense contexts: table toolbars (h-8) */ + --control-radius: var(--radius-2); + --control-padding-x: var(--space-3); + --control-padding-x-sm: var(--space-2); /* pairs with --control-height-sm */ + + /* Box model — a container's inner padding; one size per column. + The spacing scale governs space BETWEEN siblings, boxes the space WITHIN. */ + --box-padding-sm: var(--space-2); /* dense tables, chips */ + --box-padding: var(--space-3); /* cards, dialogs, widgets (default) */ + --box-padding-lg: var(--space-4); /* spacious panels, sheets */ +} + +/* ════════════════ DARK ════════════════ + + Every page Visual Stack ships renders in both themes, and a page asks for a + role rather than a colour so one stylesheet serves both. That makes dark part + of the source here, not a remap a project does later. + + The semantic tier alone is restated. Neutrals keep the purple cast; the red + and green lift, because a hue that holds 4.5:1 on white does not hold it on a + dark ground. Tints become translucent so they sit on whatever is beneath. */ +:root[data-theme="dark"] { + color-scheme: dark; + + --ground: #0e0c13; + --background: #171320; + --card: #171320; + --popover: #171320; + --foreground: #f3f1f7; + --foreground-2: #bdb7c8; + --muted: #1e1a28; + --muted-foreground: #8d86a0; + --foreground-disabled: #625b73; + --border: #2a2534; + --border-strong: #372f45; + --input: #372f45; + + --primary: #ff6b60; + --primary-hover: #ff8a81; + --primary-active: #ffa79f; + --primary-subtle: rgba(255, 107, 96, 0.14); + --primary-border: rgba(255, 107, 96, 0.38); + --primary-foreground: #171320; + --link: #ff6b60; + --ring: #ff6b60; + + --success: #2fb89b; + --success-strong: #6fd9c2; + --success-subtle: rgba(47, 184, 155, 0.14); + --success-border: rgba(47, 184, 155, 0.38); + + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-2: 0 4px 16px rgba(0, 0, 0, 0.5); + --shadow-3: 0 16px 48px rgba(0, 0, 0, 0.6); + --scrim: rgba(0, 0, 0, 0.6); +} + +/* Reduced motion — essential feedback never rides on motion alone */ +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + --duration-pulse: 0ms; + } +} diff --git a/docs/assets/wireframe-demo.gif b/docs/assets/wireframe-demo.gif index b5ba52c..15e44b8 100644 Binary files a/docs/assets/wireframe-demo.gif and b/docs/assets/wireframe-demo.gif differ diff --git a/docs/demo/pages/v1.html b/docs/demo/pages/v1.html new file mode 100644 index 0000000..3136de5 --- /dev/null +++ b/docs/demo/pages/v1.html @@ -0,0 +1,61 @@ + + + + +Today + + +

Today

+

Monday, 3 August · 6 tasks

+ +
+
Add a task…
+ +
+ +
+
    +
  • Buy groceries for the week
  • +
  • Water the plants
  • +
  • Call the dentist
  • +
  • Ship the release notes
  • +
  • Morning run
  • +
  • Pay the electricity bill
  • +
+
+ +
4 to do · 2 done
diff --git a/docs/demo/pages/v2.html b/docs/demo/pages/v2.html new file mode 100644 index 0000000..ca75545 --- /dev/null +++ b/docs/demo/pages/v2.html @@ -0,0 +1,76 @@ + + + + +Today + + +

Today

+

Monday, 3 August · 6 tasks

+ +
+
Add a task…
+ + +
+ +
+
    +
  • Buy groceries for the week
  • +
  • Call the dentist
  • +
  • Ship the release notes
  • +
  • Pay the electricity bill
  • +
+

Done · 2

+
    +
  • Water the plants
  • +
  • Morning run
  • +
+
+ +
4 to do · 2 done
diff --git a/docs/demo/record-demo.mjs b/docs/demo/record-demo.mjs new file mode 100644 index 0000000..cce715d --- /dev/null +++ b/docs/demo/record-demo.mjs @@ -0,0 +1,419 @@ +/* + * Records the README demo and writes docs/assets/wireframe-demo.gif. + * + * Nothing here is staged: a real review server serves the page in + * `pages/v1.html`, headless Chrome drives the workspace the way a reviewer + * would, and the agent's turn is played by the review CLI — take delivery of + * the round, swap in `pages/v2.html`, publish it back. The workspace is the + * shipped one, so a change to it shows up in the next recording. + * + * Run it from a checkout: + * + * cd e2e && npm ci # once — playwright is borrowed from here + * node docs/demo/record-demo.mjs + * + * The dimensions and the pacing are the ones the README needs; CLAUDE.md says + * why each of them is what it is. + */ +import { spawn, spawnSync } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const REPO = path.resolve(HERE, '../..') +const SERVER = path.join(REPO, 'plugins/vstack/skills/review/assets/review-server.mjs') +const OUT = process.argv[2] || path.join(REPO, 'docs/assets/wireframe-demo.gif') + +/* Playwright is the e2e suite's dependency, not the plugin's — the plugin ships + with none, and a recording tool is not a reason to give it one. */ +let chromium +try { + ({ chromium } = createRequire(path.join(REPO, 'e2e/'))('playwright')) +} catch { + console.error('playwright is missing — run `cd e2e && npm ci` first') + process.exit(1) +} +if (spawnSync('ffmpeg', ['-version']).status !== 0) { + console.error('ffmpeg is missing — `brew install ffmpeg`') + process.exit(1) +} + +/* ── what the demo says ── */ + +const POINT_NOTE = 'Add a due-date picker here, next to the Add button.' +const AREA_NOTE = 'Completed tasks should move to a Done section at the bottom.' +const STRIKE_NOTE = 'Shorten this to just Clear.' +const V2_LABEL = 'Due-date picker and Done section' +const V2_SUMMARY = 'Added the due-date button, grouped the completed tasks, and shortened the Clear label.' + +/* ── how it is shot ── */ + +const VIEWPORT = { width: 920, height: 760 } +const TYPE_MS = 15 // fast enough to read, quick enough not to wait on +const HOLD_CAP_MS = 400 // the longest any unchanging state is shown +const FINAL_HOLD_MS = 550 // long enough to read the result, short enough to loop +const FPS = 25 +const COLORS = 256 +const PORT = 24555 +const ORIGIN = `http://127.0.0.1:${PORT}` + +/* Resolved, because on macOS the temp dir sits under the /var symlink and a + store found by one path is not recognised as the store claimed by the other. */ +const WORK = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-demo-'))) +const FRAMES = path.join(WORK, 'frames') +const PAGE = path.join(WORK, 'page.html') +fs.mkdirSync(FRAMES) + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const cli = (...argv) => { + const run = spawnSync(process.execPath, [SERVER, ...argv], { cwd: WORK, encoding: 'utf8' }) + if (run.status !== 0) throw new Error(`${argv[0]} failed:\n${run.stdout}${run.stderr}`) + return run.stdout +} + +fs.copyFileSync(path.join(HERE, 'pages/v1.html'), PAGE) +cli('publish', '--file', PAGE, '--label', 'First draft') + +/* A server left behind by a failed run still answers on this port, out of a + store that run has already deleted. Recording against it is unexplainable. */ +try { + await fetch(ORIGIN + '/api/project') + throw new Error(`something is already serving ${ORIGIN} — kill it and run again`) +} catch (error) { + if (!/ECONNREFUSED|fetch failed/.test(String(error))) throw error +} + +const server = spawn(process.execPath, [ + SERVER, 'serve', '--file', PAGE, '--port', String(PORT), + '--host', 'claude', '--no-open', '--idle-timeout', '0', +], { cwd: WORK, stdio: ['ignore', 'pipe', 'pipe'] }) +let serverLog = '' +server.stdout.on('data', chunk => { serverLog += chunk }) +server.stderr.on('data', chunk => { serverLog += chunk }) + +/* A run that throws must not leave the port held, or the next one records + against a server whose store is gone. */ +const children = [server] +process.on('exit', () => { for (const child of children) { try { child.kill() } catch {} } }) + +for (let attempt = 0; ; attempt++) { + try { if ((await fetch(ORIGIN + '/api/project')).ok) break } catch {} + if (attempt > 100) throw new Error(`server did not start:\n${serverLog}`) + await sleep(100) +} + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: VIEWPORT }) + +/* A screenshot has no pointer in it, so the recording draws its own and lets it + follow the real mouse. Reading the events rather than being told where the + pointer is keeps the drawing and the input from ever disagreeing. */ +await page.addInitScript(() => { + const install = () => { + const cursor = document.createElement('div') + cursor.id = 'vs-demo-cursor' + cursor.innerHTML = ` + + + + ` + document.body.appendChild(cursor) + addEventListener('mousemove', event => { + cursor.style.transform = `translate(${event.clientX}px,${event.clientY}px)` + }, true) + addEventListener('mousedown', () => { + cursor.classList.add('down') + cursor.classList.remove('tap') + void cursor.offsetWidth // restart the ripple on a second click in the same place + cursor.classList.add('tap') + }, true) + addEventListener('mouseup', () => cursor.classList.remove('down'), true) + } + if (document.readyState === 'loading') addEventListener('DOMContentLoaded', install) + else install() +}) + +await page.goto(ORIGIN + '/') +await page.locator('#frame').waitFor() +await page.frameLocator('#frame').locator('h1').waitFor() + +/* The workspace refits the zoom on every version load. The recording is read at + 100%, so the refit is taken out for the session and the zoom pinned once. */ +await page.evaluate(() => { window.fitZoom = () => {} }) +await page.locator('#sizeSwitch button[data-size=phone]').click() +await page.evaluate(() => window.setZoom(1)) +await sleep(400) + +/* The agent session, listening — the streaming form, which is what a real + session runs. The one-shot form has to exit to deliver a round, and the top + bar goes Unlinked in the gap before anything is watching again; this one + stays up for the whole recording. It goes live once its handshake is + answered, so the answer is read off its own output. */ +const watcher = spawn(process.execPath, [SERVER, 'watch', '--stream', '--file', PAGE], + { cwd: WORK, stdio: ['ignore', 'pipe', 'pipe'] }) +children.push(watcher) +let heard = '' +let acked = false +const delivered = new Promise(resolve => { + const read = chunk => { + heard += chunk + const token = !acked && heard.match(/--token (\S+)/) + if (token) { acked = true; cli('ack', '--file', PAGE, '--token', token[1]) } + if (/^REVIEW/m.test(heard)) resolve() + } + watcher.stdout.on('data', read) + watcher.stderr.on('data', read) +}) +// Presence is polled every few seconds, which is what this wait is for. +await page.locator('#linkDot.on').waitFor({ timeout: 20000 }) + +/** Where an element of the page under review sits on screen. The canvas is + CSS-scaled, so the mouse is aimed at a measured box, never at the element. */ +async function framedBox (selector) { + const box = await page.frameLocator('#frame').locator(selector).boundingBox() + if (!box) throw new Error(`${selector} is not on screen`) + return box +} +const middleOf = box => ({ x: box.x + box.width / 2, y: box.y + box.height / 2 }) + +/** Where a run of words inside the page under review sits on screen. Striking + words rather than a whole element is a drag between two points, and those + points are in the middle of a text node — so they are measured in the text + itself rather than off any element's box. + + The page's own coordinates are not the screen's, so the offset between them + is measured from an element whose box is known both ways rather than assumed + from where the frame sits. Assuming it puts the drag in the comments panel, + where it silently strikes nothing. */ +async function wordsBox (selector, words) { + const frame = await (await page.locator('#frame').elementHandle()).contentFrame() + /* Both boxes in one call, so the offset between them cannot be spoilt by the + canvas moving between two reads — closing a composer re-lays it out. */ + const [inner, holder] = await frame.evaluate(([where, what]) => { + const node = document.querySelector(where).firstChild + const from = node.data.indexOf(what) + if (from < 0) throw new Error(`"${what}" is not in ${where}`) + const range = document.createRange() + range.setStart(node, from) + range.setEnd(node, from + what.length) + const box = ({ x, y, width, height }) => ({ x, y, w: width, h: height }) + return [box(range.getBoundingClientRect()), + box(document.querySelector(where).getBoundingClientRect())] + }, [selector, words]) + // The element holding the words, placed on screen by the same machinery that + // aims every other click in this recording. + const onScreen = await framedBox(selector) + return { + x: onScreen.x + (inner.x - holder.x), + y: onScreen.y + (inner.y - holder.y), + w: inner.w, + h: inner.h, + } +} + +let at = { x: 470, y: 470 } +await page.mouse.move(at.x, at.y) + +/** Move the way a hand does: eased, and over enough frames to be seen moving. */ +async function glide (to, ms = 340) { + const from = at + const steps = Math.max(6, Math.round(ms / 16)) + for (let step = 1; step <= steps; step++) { + const t = step / steps + const eased = t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2 + await page.mouse.move(from.x + (to.x - from.x) * eased, from.y + (to.y - from.y) * eased) + await sleep(16) + } + at = to +} +async function glideTo (selector, ms) { + await glide(middleOf(await page.locator(selector).boundingBox()), ms) +} +async function click () { + await page.mouse.down() + await sleep(60) + await page.mouse.up() +} +async function write (note) { + const editor = page.locator('#composer textarea.cnote') + await editor.waitFor() + await editor.pressSequentially(note, { delay: TYPE_MS }) + await editor.press('Enter') + await sleep(150) +} + +/* ── frames ── */ +const frames = [] +let capturing = true +const capture = (async () => { + for (let i = 0; capturing; i++) { + /* PNG, not JPEG: a lossy re-encode perturbs every block in the frame, so + two frames that differ only by the cursor differ everywhere, and the GIF + encoder can no longer skip the parts that did not change. It is worth + about half the file size. */ + const file = path.join(FRAMES, `f${String(i).padStart(5, '0')}.png`) + try { await page.screenshot({ path: file, type: 'png' }) } catch { return } + frames.push({ file, at: Date.now() }) + } +})() + +await sleep(150) + +/* ── a point comment on the compose row ── */ +const field = await framedBox('.field') +await glide({ x: field.x + field.width * 0.62, y: field.y + field.height / 2 }, 380) +await click() +await write(POINT_NOTE) + +/* ── an area comment over the whole list ── */ +const card = await framedBox('.card') +await glide({ x: card.x - 4, y: card.y - 6 }, 340) +await page.mouse.down() +await glide({ x: card.x + card.width + 4, y: card.y + card.height + 6 }, 460) +await page.mouse.up() +await write(AREA_NOTE) + +/* ── striking words out: the drag says which words go, and a note on top of it + says what to put in their place ── */ +/* The tool by its shortcut, not by a trip to the toolbar — that is a long move + away from the page for something a reviewer does with one key. The composer + has to be shut and unfocused first: a key pressed while typing is text, and a + composer left open also swallows the first press on the canvas, closing + itself instead of starting the gesture. */ +await page.evaluate(() => { window.closeComposer(); document.activeElement?.blur() }) +await sleep(250) // closing it re-lays the canvas out; measure after that +await page.keyboard.press('d') +await sleep(150) +if (await page.locator('#toolbar [data-tool=delete]').getAttribute('aria-pressed') !== 'true') { + throw new Error('pressing d did not reach the workspace — the strike tool is not selected') +} +/* Left to right, the way the words read. The footer sits well below the card, + so it is clear of the area mark and of the note that mark hangs beneath + itself — the press lands on canvas rather than on a mark, which would select + that comment instead of starting a gesture. + + Both ends sit inside the words. A point just past the last character is in no + text node at all, so no caret resolves there and the strike takes nothing. */ +const words = await wordsBox('.clear', 'all completed tasks') +const wordsY = words.y + words.h / 2 +await glide({ x: words.x + 2, y: wordsY }, 340) +await sleep(120) +await page.mouse.down() +await glide({ x: words.x + words.w - 2, y: wordsY }, 440) +await page.mouse.up() +/* A strike that captured nothing opens no composer, and the Escape below would + leave Annotate rather than close one — which is how a demo ends up sent with + the mark missing and nothing saying so. */ +await page.locator('#composer.on').waitFor({ timeout: 5000 }).catch(async () => { + console.error('strike diagnostics:', JSON.stringify({ + words, + composer: await page.locator('#composer').getAttribute('class'), + toast: await page.locator('.vs-toast').textContent().catch(() => null), + tool: await page.locator('#toolbar [data-tool=delete]').getAttribute('aria-pressed'), + mode: await page.locator('#modeSwitch [data-mode=annotate]').getAttribute('aria-pressed'), + comments: (await (await fetch(ORIGIN + '/api/project')).json()).comments.map(c => c.kind), + })) + throw new Error('the strike captured nothing') +}) +await sleep(120) +await write(STRIKE_NOTE) + +/* ── sending the round ── */ +/* Three marks were made, so three must be on the review before it is sent — a + gesture that quietly captured nothing would otherwise ship as a demo of two. + Saving is a request in flight, so this waits for it rather than sampling. */ +let marks = [] +for (let attempt = 0; attempt < 40 && marks.length < 3; attempt++) { + marks = (await (await fetch(ORIGIN + '/api/project')).json()).comments + if (marks.length < 3) await sleep(100) +} +if (marks.length !== 3) { + throw new Error(`three marks were made, ${marks.length} reached the review: ` + + marks.map(mark => mark.kind).join(', ')) +} +await glideTo('#btnSend', 380) +await click() +await delivered + +/* ── the agent's turn ── */ +const { comments } = await (await fetch(ORIGIN + '/api/project')).json() +const ids = comments.map(comment => comment.id).join(',') +fs.copyFileSync(path.join(HERE, 'pages/v2.html'), PAGE) +cli('publish', '--file', PAGE, '--close', ids, '--label', V2_LABEL, '--summary', V2_SUMMARY) + +await page.locator('#workBanner.on').waitFor() +await glideTo('#btnRefresh', 340) +await click() +await page.frameLocator('#frame').locator('.group').waitFor() +/* The toast covers the version it is announcing, and sitting through its life + and its fade is most of a second at the end of a GIF that loops. Take it off + rather than wait it out. */ +await page.evaluate(() => document.querySelector('.vs-toast')?.remove()) +await sleep(200) + +capturing = false +await capture +await browser.close() +for (const child of children) child.kill() + +const shot = (frames.at(-1).at - frames[0].at) / 1000 +console.log(`captured ${frames.length} frames over ${shot.toFixed(1)}s`) + +/* ── assembly ── */ + +/* One entry per state on screen: the first frame that showed it, and how long + it stayed. Capture runs well above the frame rate, so a still page is dozens + of copies, and a wait the reviewer sat through must not become a wait the + reader sits through. */ +const states = [] +for (const [index, frame] of frames.entries()) { + const hash = crypto.createHash('md5').update(fs.readFileSync(frame.file)).digest('hex') + const next = frames[index + 1] + const held = (next ? next.at : frame.at + 40) - frame.at + const last = states.at(-1) + if (last?.hash === hash) last.ms += held + else states.push({ hash, file: frame.file, ms: held }) +} +for (const state of states) state.ms = Math.min(state.ms, HOLD_CAP_MS) +states.at(-1).ms = FINAL_HOLD_MS + +const listFile = path.join(WORK, 'frames.txt') +const list = states + .map(state => `file '${state.file}'\nduration ${(state.ms / 1000).toFixed(3)}`) + .join('\n') +// The concat demuxer ignores the last entry's duration unless the file repeats. +fs.writeFileSync(listFile, `ffconcat version 1.0\n${list}\nfile '${states.at(-1).file}'\n`) + +const ffmpeg = (...args) => { + const run = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', ...args], + { encoding: 'utf8' }) + if (run.status !== 0) throw new Error(run.stderr || 'ffmpeg failed') +} +const palette = path.join(WORK, 'palette.png') +const concat = ['-f', 'concat', '-safe', '0', '-i', listFile] +ffmpeg(...concat, '-vf', `fps=${FPS},palettegen=max_colors=${COLORS}:stats_mode=full`, + '-y', palette) +// No dithering: the page is flat colour, and dither noise defeats the encoder. +ffmpeg(...concat, '-i', palette, '-lavfi', + `fps=${FPS}[x];[x][1:v]paletteuse=dither=none:diff_mode=rectangle`, '-loop', '0', '-y', OUT) + +const seconds = states.reduce((total, state) => total + state.ms, 0) / 1000 +console.log(`${states.length} states, ${seconds.toFixed(1)}s, ` + + `${(fs.statSync(OUT).size / 1024).toFixed(0)} KB -> ${OUT}`) + +fs.rmSync(WORK, { recursive: true, force: true }) diff --git a/docs/review-wishlist.md b/docs/review-wishlist.md index b0a8e69..673a342 100644 --- a/docs/review-wishlist.md +++ b/docs/review-wishlist.md @@ -3,6 +3,40 @@ Features that have been considered for the review tool and are not being built yet. Each entry says what it has to do to ship. An entry stays here until its acceptance criteria can be met. +## Derive review state from an event log + +**What it should do.** A review's store holds an append-only log of events — sent, delivered, +replied, closed, claimed — and every reader derives state by replaying it. The same log and the +same question always give the same answer, so the gate's verdict is reproducible, a dispute about +how a round got into a state is settled by reading the log, and contention is settled by append +order instead of by locks. + +**Acceptance criteria.** + +1. The CLI surface is unchanged: every subcommand answers exactly as it does today, derived from + the log instead of from mutated records. +2. Replaying a store's log reproduces its state, and `unanswered --session ` is a pure + function of the log and the id. A test is a hand-written log and an expected verdict. +3. The first `claimed` event in the log wins a contested store. A watcher that lost reads that + back and stands down, so two watchers sweeping the same store in the same second can no longer + both adopt it. +4. A store written by the current version is read as the log's seed state. Nothing is migrated + behind the user's back. +5. A line torn by a crash mid-append is ignored by every reader, and events are single-line + appends small enough to be atomic on a local filesystem. + +**What it needs.** A rewrite of the persistence layer in `review-server.mjs` — every subcommand +that reads or writes round state, a fold that derives state from events, and the test suites +rebuilt around log fixtures. This is the engine's core rewritten while it works, so it ships as +its own release, not alongside a feature. Liveness stays out of the log: the `watching` heartbeat +is ephemeral and remains a file beside it. + +**Until then.** Ownership is a recorded field. Delivery stamps `deliveredTo` with the watcher's +`--session` id, the Stop hook asks `unanswered --session `, and `watch --all` skips a store +whose heartbeat says another watcher covers it. The one gap this leaves is criterion 3's race: +two watchers that scan the same unclaimed store in the same moment can both adopt it, and the +round then belongs to whichever delivered last. + ## Stop a round in flight **Status: withdrawn on 5 August 2026**, after an implementation that could not meet criterion 1. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..7ef195f --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,48 @@ +# End-to-end suite + +Gherkin scenarios that drive the review loop end to end: the real +`review-server.mjs` and its CLI, with reviewer actions going through the same +HTTP API the workspace uses. + +No model is involved. The agent role is played by `support/mock-agent.mjs`, +which runs the same CLI commands a real session would (`watch`, `publish`, +`reply`, `unanswered`) — the protocol never sees who is typing, so the mock is +a complete stand-in for Claude or Codex. Each scenario gets its own temp +directory and port. The `@browser` scenarios additionally drive the workspace +UI in a headless Chromium via Playwright. + +## Run it + +```bash +npm ci +npx playwright install chromium # once, for the @browser scenarios +npx cucumber-js # VSTACK_HOST=claude (default) +VSTACK_HOST=codex npx cucumber-js # same suite under the Codex profile +``` + +CI runs both hosts on every pull request (`E2E (claude)` and `E2E (codex)` +checks), with a result summary on the run page. + +## Tags + +| Tag | Meaning | +| --- | --- | +| `@round1` | Drives the server API and CLI headlessly. Runs by default. | +| `@browser` | Drives the workspace UI in Chromium via Playwright. Runs by default. | +| `@agent` | Puts a real model session behind the loop; costs money, excluded by default. | + +The defaults are set in `cucumber.mjs`. + +## Layout + +| Path | Role | +| --- | --- | +| `features/*.feature` | The scenarios, one file per feature | +| `steps/review.steps.mjs` | Step definitions for the protocol scenarios | +| `steps/browser.steps.mjs` | Step definitions for the `@browser` scenarios | +| `support/world.mjs` | Per-scenario server, temp dir, and helpers | +| `support/mock-agent.mjs` | The agent role, as CLI calls | +| `support/browser.mjs` | Chromium lifecycle for `@browser` scenarios | + +This is the only directory in the repo with a `package.json`. It stays outside +`plugins/` so the plugin ships without dependencies. diff --git a/e2e/cucumber.mjs b/e2e/cucumber.mjs new file mode 100644 index 0000000..5ac7032 --- /dev/null +++ b/e2e/cucumber.mjs @@ -0,0 +1,8 @@ +export default { + import: ['support/**/*.mjs', 'steps/**/*.mjs'], + // @agent spends money on a real model session, so it runs only when asked + // for by tag. @browser scenarios run by default and need Chromium once: + // npx playwright install chromium + tags: 'not @agent', + format: ['progress'], +} diff --git a/e2e/features/browser.feature b/e2e/features/browser.feature new file mode 100644 index 0000000..5b724db --- /dev/null +++ b/e2e/features/browser.feature @@ -0,0 +1,119 @@ +Feature: The workspace in a real browser + Playwright drives the same workspace a reviewer uses: the pin a click drops, + the on-canvas composer, View mode hiding the marks, Send handing the round + over, and the confirm dialog behind Clear all. + + Background: + Given a page is under review + And the reviewer opens the workspace + + @browser + Scenario: B1 — the workspace frames the page under review + Then the tab is titled for the review of "Review e2e page" + And the framed page shows the heading "Todo" + And the send button is labelled for the host + + @browser + Scenario: B2 — a click drops a pin and Enter saves the comment + When the reviewer clicks the page and writes "Make it pop" + Then a pin marks the comment on the canvas + And the comment "Make it pop" is a draft on the review + + @browser + Scenario: B3 — an empty note is discarded on dismiss + When the reviewer clicks the page and dismisses the empty note + Then the canvas shows no pins + And the review has no comments on disk + + @browser + Scenario: B4 — View mode hides every annotation + When the reviewer clicks the page and writes "Make it pop" + And the reviewer switches to View + Then the canvas shows no pins + + @browser + Scenario: B5 — Send hands the round to the agent + When the reviewer clicks the page and writes "Make it pop" + And the reviewer presses Send + Then the comment "Make it pop" is queued, not delivered + When the agent takes delivery + Then the delivery names 1 open comment, 1 new + + @browser + Scenario: B7 — a general comment is saved by Enter, and not sent + When the reviewer starts a general comment "The whole thing reads cold" + Then the general comment editor offers Save and the newline hint + When the reviewer saves it with Enter + Then the general comment editor is closed + And the comment "The whole thing reads cold" is a draft on the review + + @browser + Scenario: B9 — a finished round brings its summary with it + When the reviewer clicks the page and writes "Make it pop" + And the reviewer presses Send + And the agent takes delivery + And the agent closes "Make it pop", publishes "Bigger heading" and summarises "Raised the heading to 32px and gave it more room above." + Then the banner says the round is done and shows "Raised the heading to 32px and gave it more room above." + When the reviewer presses the summary chevron + Then the summary is folded away behind the chevron + When the reviewer presses the summary chevron + Then the summary is open + + @browser + Scenario: B12 — the summary arrives the way it was left + When the reviewer clicks the page and writes "Make it pop" + And the reviewer presses Send + And the agent takes delivery + And the agent closes "Make it pop", publishes "Bigger heading" and summarises "First round." + Then the banner says the round is done and shows "First round." + When the reviewer presses the summary chevron + Then the summary is folded away behind the chevron + When the reviewer opens the workspace + And the reviewer adds a general comment "One more thing" + And the reviewer presses Send + And the agent takes delivery + And the agent closes "One more thing", publishes "Second pass" and summarises "Second round." + Then the banner carries "Second round." with it folded away + + @browser + Scenario: B8 — the comments panel is resized and stays where it is put + When the reviewer drags the panel edge 80px wider + Then the comments panel is 80px wider + When the reviewer opens the workspace + Then the comments panel keeps its width + + @browser + Scenario: B6 — Clear all leaves an open comment alone unless asked + When the reviewer clicks the page and writes "Make it pop" + And the reviewer presses Send + And the reviewer opens Clear all + And the reviewer confirms clearing + Then the workspace still shows the comment "Make it pop" + When the reviewer opens Clear all + And the reviewer chooses to clear the open ones too + And the reviewer confirms clearing + Then the workspace shows no comments + + @browser + Scenario: B11 — a question can be answered by picking an option + When the reviewer clicks the page and writes "Sort the overdue ones first" + And the reviewer presses Send + And the agent takes delivery + And the agent asks "Every overdue row, or only the ones assigned to you?" on "Sort the overdue ones first" offering "Every overdue row" and "Only mine", recommending 2 + Then the comment offers "Every overdue row" and "Only mine", with "Only mine" recommended + When the reviewer picks "Only mine" + Then the thread ends with "Only mine" from the reviewer + + @browser + Scenario: B10 — Clear all takes the addressed and keeps the rest + When the reviewer clicks the page and writes "Make it pop" + And the reviewer presses Send + And the agent takes delivery + And the agent closes "Make it pop" and publishes "Done" + Then the workspace shows 1 comment as addressed + And nothing is folded into Earlier + When the reviewer adds a general comment "Still thinking about this" + And the reviewer opens Clear all + And the reviewer confirms clearing + Then the workspace still shows the comment "Still thinking about this" + And the record of "Make it pop" is closed and marked dismissed diff --git a/e2e/features/clearing.feature b/e2e/features/clearing.feature new file mode 100644 index 0000000..7d4313e --- /dev/null +++ b/e2e/features/clearing.feature @@ -0,0 +1,42 @@ +Feature: Page review — clearing + Three distinct acts: Clear all takes every comment off the list, Clear + history removes past versions and keeps the present, and a hard reset + restarts the review at v1 without undoing the agent's edits. + + Background: + Given a page is under review + + @round1 + Scenario: S7 — Clear all empties the list without touching versions + Given the reviewer has sent a comment "C" + And the agent has taken delivery + And the agent closes "C" and publishes "C done" + And the reviewer has sent a comment "A" + And the agent has taken delivery + And the reviewer has sent a comment "B" + When the reviewer clears all comments + Then the workspace shows no comments + And no record remains of "B" + And the record of "A" is closed and marked dismissed + And the version history is untouched + And the agent can still close "A" + + @round1 + Scenario: S8 — Clear history deletes past versions and keeps the present + Given the review has reached version 3 + And the reviewer has sent a comment "A" + When the reviewer clears the history + Then only version 3 remains on the timeline + And the review is still at version 3 + And the comment "A" is still on the review + + @round1 + Scenario: S9 — a hard reset restarts the review at v1 + Given the reviewer has sent a comment "A" + And the agent has taken delivery + And the agent edits the page + And the agent closes "A" and publishes "Edited" + When the reviewer hard-resets the review + Then the review starts again at version 1 + And the workspace shows no comments + And the page keeps the agent's edits diff --git a/e2e/features/closing.feature b/e2e/features/closing.feature new file mode 100644 index 0000000..c69a1ef --- /dev/null +++ b/e2e/features/closing.feature @@ -0,0 +1,36 @@ +Feature: Page review — closing, versions, threads + Only the agent closes a comment. A publish freezes the page as the next + version; a reply keeps the conversation going without changing state, and a + reviewer reply to a closed comment reopens it. + + Background: + Given a page is under review + + @round1 + Scenario: S4 — publish closes the comment and freezes the next version + Given the reviewer has sent a comment "Make the title bigger" + And the agent has taken delivery + When the agent edits the page + And the agent closes "Make the title bigger" and publishes "Bigger title" + Then the review is at version 2 + And version 2 is a frozen copy of the page labelled "Bigger title" + And the comment "Make the title bigger" is closed + + @round1 + Scenario: S5 — an agent reply asks a question and keeps the comment open + Given the reviewer has sent a comment "Sort the list" + And the agent has taken delivery + When the agent replies "By date, or by name?" to "Sort the list" + Then the thread on "Sort the list" has an agent reply "By date, or by name?" + And the comment "Sort the list" is open + And nothing is left unanswered + + @round1 + Scenario: S6 — a reviewer reply to a closed comment reopens it + Given the reviewer has sent a comment "A" + And the agent has taken delivery + And the agent closes "A" and publishes "Done" + When the reviewer replies "Not quite — bolder too" to "A" + Then the comment "A" is open + When the agent takes delivery + Then the brief carries the reply "Not quite — bolder too" diff --git a/e2e/features/edge-cases.feature b/e2e/features/edge-cases.feature new file mode 100644 index 0000000..21cdaa8 --- /dev/null +++ b/e2e/features/edge-cases.feature @@ -0,0 +1,52 @@ +Feature: Edge cases + The liveness rules: nothing the reviewer does can stop the agent finishing, + a stranded round can be requeued only once nothing is listening, approval + ends the review deliberately, and a retried close is safe. + + Background: + Given a page is under review + + @round1 + Scenario: S13 — withdrawing a delivered comment never blocks the agent + Given the reviewer has sent a comment "A" + And the agent has taken delivery + When the reviewer withdraws "A" + Then the workspace shows no comments + And the record of "A" is closed and marked dismissed + And the agent can still close "A" + + @round1 + Scenario: S14a — requeue is refused while the agent is listening + Given the reviewer has sent a comment "A" + And the agent has taken delivery + And the agent's watching heartbeat is fresh + When the workspace asks to requeue + Then the server refuses the requeue + And the comment "A" has been sent and delivered + + @round1 + Scenario: S14b — requeue rescues a dead session's round + Given the reviewer has sent a comment "A" + And the agent has taken delivery + And nothing is listening + When the workspace asks to requeue + Then the comment "A" is queued, not delivered + When the agent takes delivery + Then the delivery names 1 open comment, 1 new + + @round1 + Scenario: S15 — approve ends the review deliberately + Given the reviewer has sent comments "A" and "B" + And the agent has taken delivery + When the reviewer approves the design expecting 2 open comments + Then the approval records 2 open comments + And the server exits on its own + + @round1 + Scenario: S16 — closing twice is a no-op + Given the reviewer has sent a comment "A" + And the agent has taken delivery + And the agent closes "A" and publishes "Done" + When the agent closes "A" again + Then the command succeeds + And the review is still at version 2 diff --git a/e2e/features/framing.feature b/e2e/features/framing.feature new file mode 100644 index 0000000..80efe4d --- /dev/null +++ b/e2e/features/framing.feature @@ -0,0 +1,34 @@ +Feature: Long pages and overlays on the canvas + A page taller than the canvas is scrolled two different ways — the canvas + scrolls it when the frame grew to the whole document, the page scrolls itself + when it did not — and a comment has to land on what was under the pointer in + both. An overlay the page opens has to appear where the reviewer is looking, + not screens below it. + + @browser + Scenario: F1 — a comment lands on what was clicked after the canvas scrolls + Given a long page is under review + And the reviewer opens the workspace + When the reviewer scrolls the canvas to the bottom + And the reviewer clicks "#tail" in the framed page and writes "Down here" + Then a pin marks the comment on the canvas + And the comment "Down here" is anchored to "tail" + + @browser + Scenario: F2 — a comment lands on what was clicked after the page scrolls itself + Given a page that keeps its own scrollbar is under review + And the reviewer opens the workspace + When the reviewer scrolls the framed page to the bottom + And the reviewer clicks "#tail" in the framed page and writes "Still here" + Then a pin marks the comment on the canvas + And the comment "Still here" is anchored to "tail" + + @browser + Scenario: F3 — a dialog the page opens lands on screen, and the fit comes back + Given a long page is under review + And the reviewer opens the workspace + When the reviewer switches to View + And the framed page opens its confirmation dialog + Then the dialog is where the reviewer is looking + When the framed page closes its confirmation dialog + Then the canvas fits the whole page again diff --git a/e2e/features/host-matrix.feature b/e2e/features/host-matrix.feature new file mode 100644 index 0000000..7ed13d7 --- /dev/null +++ b/e2e/features/host-matrix.feature @@ -0,0 +1,15 @@ +Feature: Host matrix — Claude and Codex + The protocol is host-independent; the host selects a profile that the server + stamps into the workspace. CI runs the whole suite under VSTACK_HOST=claude + and VSTACK_HOST=codex; these scenarios pin the differences that remain. + + @round1 + Scenario Outline: the workspace is stamped with the host profile + Given a page is under review with host "" + Then the workspace injects the "" profile named "" + And the injected share capability is "" + + Examples: + | host | name | share | + | claude | Claude | artifact | + | codex | Codex | copy | diff --git a/e2e/features/linking.feature b/e2e/features/linking.feature new file mode 100644 index 0000000..97cfbd9 --- /dev/null +++ b/e2e/features/linking.feature @@ -0,0 +1,16 @@ +Feature: The stream watcher links a session + How a real agent session stays wired: it arms `watch --all --stream`, answers + the HANDSHAKE with `ack`, and from then on rounds arrive as REVIEW events + while its heartbeat proves to the server that someone is listening. + + @round1 + Scenario: S17 — the stream watcher handshakes, links, and receives the round + Given a page is under review + When the agent arms the stream watcher + Then the watcher asks for a handshake + When the agent answers the handshake + Then the watcher reports LINKED + And the agent's presence is heartbeated + When the reviewer sends a comment "A" + Then the watcher receives a REVIEW event + And the workspace cannot requeue the round while the watcher lives diff --git a/e2e/features/live.feature b/e2e/features/live.feature new file mode 100644 index 0000000..74324e4 --- /dev/null +++ b/e2e/features/live.feature @@ -0,0 +1,38 @@ +Feature: Live app review + The same loop pointed at a running app. Comments carry the route they were + made on, a publish is a marker rather than a file snapshot, and nothing the + review does touches the app. + + Background: + Given an app is running and under live review + + @round1 + Scenario: S10 — a live comment carries the route it was made on + When the reviewer sends a comment "Tighten this" on route "/settings" + And the agent takes delivery + Then the delivery names 1 open comment, 1 new + And the brief names the route "/settings" on "Tighten this" + + @round1 + Scenario: S11 — a live publish is a marker, not a file snapshot + Given the reviewer has sent a comment "A" on route "/" + And the agent has taken delivery + When the agent closes "A" and publishes "Fixed spacing" + Then the comment "A" is closed + And no version file was frozen + + @browser + Scenario: S17 — a live round announces itself through its summary + Given the reviewer has sent a comment "A" on route "/" + And the agent has taken delivery + And the reviewer opens the workspace + When the agent closes "A", publishes "Fixed spacing" and summarises "Tightened the header spacing in Header.tsx." + Then the banner says the round is done and shows "Tightened the header spacing in Header.tsx." + + @round1 + Scenario: S12 — a hard reset in a live review deletes comments only + Given the reviewer has sent a comment "A" on route "/" + And the agent has taken delivery + When the reviewer hard-resets the review + Then the workspace shows no comments + And the app is untouched diff --git a/e2e/features/sending.feature b/e2e/features/sending.feature new file mode 100644 index 0000000..7a0c607 --- /dev/null +++ b/e2e/features/sending.feature @@ -0,0 +1,37 @@ +Feature: Page review — sending comments + A sent comment reaches the agent immediately when no round is in flight, + and queues behind the round when one is. Whatever the agent does not close + comes back on the next delivery. + + Background: + Given a page is under review + + @round1 + Scenario: S1 — send with no round in flight is delivered immediately + When the reviewer sends a comment "Make the title bigger" + And the agent takes delivery + Then the delivery names 1 open comment, 1 new + And the brief lists "Make the title bigger" as new + And the comment "Make the title bigger" has been sent and delivered + + @round1 + Scenario: S2 — a comment sent mid-round is queued and picked up after the round + Given the reviewer has sent a comment "A" + And the agent has taken delivery + When the reviewer sends a comment "B" + Then the comment "B" is queued, not delivered + When the agent closes "A" and publishes "Round one done" + And the agent takes delivery + Then the delivery names 1 open comment, 1 new + And the brief lists "B" as new + + @round1 + Scenario: S3 — whatever is not closed comes back + Given the reviewer has sent comments "A" and "B" + And the agent has taken delivery + When the agent closes "A" and publishes "Only A" + And the reviewer sends a comment "C" + And the agent takes delivery + Then the delivery names 2 open comments, 1 new + And the brief lists "B" as not new + And the brief lists "C" as new diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..bbdc9ef --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,1811 @@ +{ + "name": "vstack-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vstack-e2e", + "devDependencies": { + "@cucumber/cucumber": "^11.2.0", + "playwright": "^1.50.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cucumber/ci-environment": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-10.0.1.tgz", + "integrity": "sha512-/+ooDMPtKSmvcPMDYnMZt4LuoipfFfHaYspStI4shqw8FyKcfQAmekz6G+QKWjQQrvM+7Hkljwx58MEwPCwwzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cucumber/cucumber": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-11.3.0.tgz", + "integrity": "sha512-1YGsoAzRfDyVOnRMTSZP/EcFsOBElOKa2r+5nin0DJAeK+Mp0mzjcmSllMgApGtck7Ji87wwy3kFONfHUHMn4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/ci-environment": "10.0.1", + "@cucumber/cucumber-expressions": "18.0.1", + "@cucumber/gherkin": "30.0.4", + "@cucumber/gherkin-streams": "5.0.1", + "@cucumber/gherkin-utils": "9.2.0", + "@cucumber/html-formatter": "21.10.1", + "@cucumber/junit-xml-formatter": "0.7.1", + "@cucumber/message-streams": "4.0.1", + "@cucumber/messages": "27.2.0", + "@cucumber/tag-expressions": "6.1.2", + "assertion-error-formatter": "^3.0.0", + "capital-case": "^1.0.4", + "chalk": "^4.1.2", + "cli-table3": "0.6.5", + "commander": "^10.0.0", + "debug": "^4.3.4", + "error-stack-parser": "^2.1.4", + "figures": "^3.2.0", + "glob": "^10.3.10", + "has-ansi": "^4.0.1", + "indent-string": "^4.0.0", + "is-installed-globally": "^0.4.0", + "is-stream": "^2.0.0", + "knuth-shuffle-seeded": "^1.0.6", + "lodash.merge": "^4.6.2", + "lodash.mergewith": "^4.6.2", + "luxon": "3.6.1", + "mime": "^3.0.0", + "mkdirp": "^2.1.5", + "mz": "^2.7.0", + "progress": "^2.0.3", + "read-package-up": "^11.0.0", + "semver": "7.7.1", + "string-argv": "0.3.1", + "supports-color": "^8.1.1", + "type-fest": "^4.41.0", + "util-arity": "^1.1.0", + "yaml": "^2.2.2", + "yup": "1.6.1" + }, + "bin": { + "cucumber-js": "bin/cucumber.js" + }, + "engines": { + "node": "18 || 20 || 22 || >=23" + }, + "funding": { + "url": "https://opencollective.com/cucumber" + } + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-18.0.1.tgz", + "integrity": "sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-30.0.4.tgz", + "integrity": "sha512-pb7lmAJqweZRADTTsgnC3F5zbTh3nwOB1M83Q9ZPbUKMb3P76PzK6cTcPTJBHWy3l7isbigIv+BkDjaca6C8/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=26" + } + }, + "node_modules/@cucumber/gherkin-streams": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-5.0.1.tgz", + "integrity": "sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "9.1.0", + "source-map-support": "0.5.21" + }, + "bin": { + "gherkin-javascript": "bin/gherkin" + }, + "peerDependencies": { + "@cucumber/gherkin": ">=22.0.0", + "@cucumber/message-streams": ">=4.0.0", + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/gherkin-streams/node_modules/commander": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.1.0.tgz", + "integrity": "sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", + "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^31.0.0", + "@cucumber/messages": "^27.0.0", + "@teppeis/multimaps": "3.0.0", + "commander": "13.1.0", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", + "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=26" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", + "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "10.0.0" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", + "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "10.0.0" + } + }, + "node_modules/@cucumber/gherkin/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "21.10.1", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.10.1.tgz", + "integrity": "sha512-isaaNMNnBYThsvaHy7i+9kkk9V3+rhgdkt0pd6TCY6zY1CSRZQ7tG6ST9pYyRaECyfbCeF7UGH0KpNEnh6UNvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/junit-xml-formatter": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.7.1.tgz", + "integrity": "sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/query": "^13.0.2", + "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", + "xmlbuilder": "^15.1.1" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/message-streams": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", + "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=17.1.1" + } + }, + "node_modules/@cucumber/messages": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", + "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "11.0.5" + } + }, + "node_modules/@cucumber/query": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", + "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.1.2.tgz", + "integrity": "sha512-xa3pER+ntZhGCxRXSguDTKEHTZpUUsp+RzTRNnit+vi5cqnk6abLdSLg5i3HZXU3c74nQ8afQC6IT507EN74oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@teppeis/multimaps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", + "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error-formatter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/assertion-error-formatter/-/assertion-error-formatter-3.0.0.tgz", + "integrity": "sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff": "^4.0.1", + "pad-right": "^0.2.2", + "repeat-string": "^1.6.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-ansi": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-4.0.1.tgz", + "integrity": "sha512-Qr4RtTm30xvEdqUXbSBVWDu+PrTokJOwe/FU+VdfJPk+MXAPoeOzKpRyrDTnZIJwAkQ4oBLTU53nu0HrkF/Z2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/knuth-shuffle-seeded": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/knuth-shuffle-seeded/-/knuth-shuffle-seeded-1.0.6.tgz", + "integrity": "sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "seed-random": "~2.2.0" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz", + "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pad-right": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/pad-right/-/pad-right-0.2.2.tgz", + "integrity": "sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==", + "dev": true, + "license": "MIT", + "dependencies": { + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/seed-random": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", + "integrity": "sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/util-arity": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/util-arity/-/util-arity-1.1.0.tgz", + "integrity": "sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..e1f167d --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "vstack-e2e", + "private": true, + "type": "module", + "description": "End-to-end suite for the review loop, driven through the real server API and CLI", + "scripts": { + "test": "cucumber-js" + }, + "devDependencies": { + "@cucumber/cucumber": "^11.2.0", + "playwright": "^1.50.0" + } +} diff --git a/e2e/steps/browser.steps.mjs b/e2e/steps/browser.steps.mjs new file mode 100644 index 0000000..e227c57 --- /dev/null +++ b/e2e/steps/browser.steps.mjs @@ -0,0 +1,387 @@ +import { Given, Then, When } from '@cucumber/cucumber' +import assert from 'node:assert/strict' +import * as agent from '../support/mock-agent.mjs' +import { PAGES } from '../support/world.mjs' + +const HOST_NAMES = { claude: 'Claude', codex: 'Codex' } + +/** Poll an assertion until it holds — the library-mode stand-in for test-runner expect. */ +async function eventually (check, what, timeout = 5000) { + const start = Date.now() + let lastError + while (Date.now() - start < timeout) { + try { return await check() } catch (error) { lastError = error } + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`${what}\n${lastError}`) +} + +/** Click the canvas over the page's heading. The canvas is CSS-scaled, so the + click goes through the mouse at the overlay's on-screen box, not through an + element-relative position that a transform would misplace. */ +async function clickCanvas (world) { + const overlay = world.browserPage.locator('#overlay') + await overlay.waitFor() + const box = await overlay.boundingBox() + await world.browserPage.mouse.click(box.x + box.width / 8, box.y + 30) +} + +When('the reviewer opens the workspace', async function () { + await this.browserPage.goto(this.origin + this.base + '/') + await this.browserPage.locator('#frame').waitFor() +}) + +Then('the tab is titled for the review of {string}', async function (name) { + await eventually(async () => { + const title = await this.browserPage.title() + assert.ok(title.includes(`${name} — Review`), `tab says "${title}"`) + }, `the tab is titled for ${name}`) +}) + +Then('the framed page shows the heading {string}', async function (heading) { + const framed = this.browserPage.frameLocator('#frame').locator('#title') + await framed.waitFor() + assert.equal((await framed.textContent()).trim(), heading) +}) + +Then('the send button is labelled for the host', async function () { + const label = await this.browserPage.locator('#btnSend').textContent() + assert.ok(label.includes(HOST_NAMES[this.hostId]), + `"${label}" names the ${this.hostId} profile`) +}) + +When('the reviewer clicks the page and writes {string}', async function (note) { + await clickCanvas(this) + const editor = this.browserPage.locator('#composer textarea.cnote') + await editor.waitFor() + await editor.fill(note) + await editor.press('Enter') + await eventually(() => { this.byNote(note) }, 'the comment reaches the server') +}) + +When('the reviewer clicks the page and dismisses the empty note', async function () { + await clickCanvas(this) + await this.browserPage.locator('#composer textarea.cnote').waitFor() + await this.browserPage.keyboard.press('Escape') +}) + +Then('a pin marks the comment on the canvas', async function () { + await this.browserPage.locator('#overlay .mark').first().waitFor() +}) + +Then('the canvas shows no pins', async function () { + await eventually(async () => { + const marks = this.browserPage.locator('#overlay .mark') + for (let i = 0; i < await marks.count(); i++) { + assert.equal(await marks.nth(i).isVisible(), false, 'a mark is still visible') + } + }, 'every pin is off the canvas') +}) + +Then('the comment {string} is a draft on the review', async function (note) { + // The save is a request in flight, so give it the moment it needs to land. + await eventually(() => { + assert.equal(this.byNote(note).sentAt, null, 'still being written, not sent') + }, `"${note}" is on the review as a draft`) +}) + +Then('the review has no comments on disk', function () { + const withWords = this.stored().filter(comment => String(comment.note || '').trim()) + assert.deepEqual(withWords, []) +}) + +When('the reviewer switches to View', async function () { + await this.browserPage.locator('#modeSwitch button', { hasText: 'View' }).click() +}) + +When('the reviewer presses Send', async function () { + const note = [...this.ids.keys()].at(-1) + await this.browserPage.locator('#btnSend').click() + await eventually(() => { + assert.ok(this.byNote(note).sentAt, 'sending stamps the comment') + }, 'the send reaches the server') +}) + +/* ── the banner that announces a finished round ── */ + +Then('the banner says the round is done and shows {string}', async function (summary) { + const banner = this.browserPage.locator('#workBanner.on') + await banner.waitFor() + const headline = await banner.locator('#workText').textContent() + assert.doesNotMatch(headline, /v\d|Round \d/, `the headline still names a version: "${headline}"`) + await eventually(async () => { + assert.equal(await banner.locator('#workSummary').isVisible(), true, 'the summary is shown') + assert.equal((await banner.locator('#workSummaryText').textContent()).trim(), summary) + }, 'the summary reaches the banner') +}) + +When('the reviewer presses the summary chevron', async function () { + await this.browserPage.locator('#btnSummary').click() +}) + +Then('the summary is folded away behind the chevron', async function () { + await eventually(async () => { + assert.equal(await this.browserPage.locator('#workSummary').isVisible(), false, 'still shown') + const chevron = this.browserPage.locator('#btnSummary') + assert.equal(await chevron.isVisible(), true, 'no way back to it') + assert.equal(await chevron.getAttribute('aria-expanded'), 'false') + }, 'the summary folds away') +}) + +Then('the banner carries {string} with it folded away', async function (summary) { + await eventually(async () => { + assert.equal((await this.browserPage.locator('#workSummaryText').textContent()).trim(), summary) + assert.equal(await this.browserPage.locator('#workSummary').isVisible(), false, 'it opened itself') + assert.equal(await this.browserPage.locator('#btnSummary').isVisible(), true, 'no chevron to open it') + }, 'the next round arrives folded the way it was left') +}) + +Then('the summary is open', async function () { + await eventually(async () => { + assert.equal(await this.browserPage.locator('#workSummary').isVisible(), true, 'still folded') + assert.equal(await this.browserPage.locator('#btnSummary').getAttribute('aria-expanded'), 'true') + }, 'the summary is open') +}) + +/* ── a question with answers to pick from ── */ + +When('the agent asks {string} on {string} offering {string} and {string}, recommending {int}', + function (text, note, first, second, recommend) { + agent.askWithOptions(this, note, text, [first, second], recommend) + }) + +Then('the comment offers {string} and {string}, with {string} recommended', + async function (first, second, recommended) { + const choices = this.browserPage.locator('.item .choice') + await eventually(async () => { + assert.equal(await choices.count(), 2, 'both options are offered') + }, 'the options reach the comment') + assert.deepEqual( + (await this.browserPage.locator('.item .choice > span').allInnerTexts()) + .map(t => t.trim()), + [first, second]) + const marked = this.browserPage.locator('.item .choice.rec') + assert.equal(await marked.count(), 1, 'exactly one is recommended') + assert.match(await marked.innerText(), new RegExp(recommended)) + assert.match(await marked.innerText(), /Recommended/i) + }) + +When('the reviewer picks {string}', async function (option) { + await this.browserPage.locator('.item .choice', { hasText: option }).click() +}) + +Then('the thread ends with {string} from the reviewer', async function (text) { + await eventually(async () => { + const { comments } = await this.project() + const last = comments.flatMap(c => c.replies || []).at(-1) + assert.equal(last?.text, text) + assert.equal(last?.by, 'reviewer') + }, 'the pick is posted as the answer') +}) + +/* ── the comments panel: what it is worth typing in, and how wide it is ── */ + +When('the reviewer adds a general comment {string}', async function (note) { + await this.browserPage.locator('#btnGeneral').click() + const editor = this.browserPage.locator('.item .gnote') + await editor.waitFor() + await editor.fill(note) + await editor.press('Enter') + await eventually(() => { this.byNote(note) }, 'the comment reaches the server') +}) + +When('the reviewer starts a general comment {string}', async function (note) { + await this.browserPage.locator('#btnGeneral').click() + const editor = this.browserPage.locator('.item .gnote') + await editor.waitFor() + await editor.fill(note) +}) + +When('the reviewer saves it with Enter', async function () { + await this.browserPage.locator('.item .gnote').press('Enter') +}) + +Then('the general comment editor is closed', async function () { + await eventually(async () => { + assert.equal(await this.browserPage.locator('.item .gnote').count(), 0, 'the box is still open') + }, 'Enter closes the box it was pressed in') +}) + +Then('the general comment editor offers Save and the newline hint', async function () { + const card = this.browserPage.locator('.item', { has: this.browserPage.locator('.gnote') }) + assert.ok(await card.locator('.gsave').isVisible(), 'the Save button is there') + const hint = await card.locator('.gfoot .hint').textContent() + assert.match(hint, /Shift\+Enter/, `the hint reads "${hint}"`) +}) + +const panelWidth = world => + world.browserPage.locator('#panel').evaluate(el => el.getBoundingClientRect().width) + +When('the reviewer drags the panel edge {int}px wider', async function (by) { + this.panelWas = await panelWidth(this) + const grip = await this.browserPage.locator('#panelGrip').boundingBox() + const y = grip.y + grip.height / 2 + await this.browserPage.mouse.move(grip.x + grip.width / 2, y) + await this.browserPage.mouse.down() + await this.browserPage.mouse.move(grip.x + grip.width / 2 - by, y, { steps: 8 }) + await this.browserPage.mouse.up() +}) + +Then('the comments panel is {int}px wider', async function (by) { + await eventually(async () => { + const now = await panelWidth(this) + assert.ok(Math.abs(now - this.panelWas - by) <= 2, + `it went from ${Math.round(this.panelWas)} to ${Math.round(now)}`) + this.panelExpect = now + }, 'the panel follows the drag') +}) + +Then('the comments panel keeps its width', async function () { + await eventually(async () => { + const now = await panelWidth(this) + assert.ok(Math.abs(now - this.panelExpect) <= 2, + `it came back at ${Math.round(now)}, not ${Math.round(this.panelExpect)}`) + }, 'the width survives a reload') +}) + +/* ── long pages: the canvas scroll, the page's own scroll, and overlays ── */ + +Given('a long page is under review', async function () { + await this.startFileReview(this.hostId, PAGES.tall) +}) + +Given('a page that keeps its own scrollbar is under review', async function () { + await this.startFileReview(this.hostId, PAGES.selfScroll) +}) + +/** The window inside the frame. It scrolls on its own whenever fitting the + frame to the page would only make the page taller. */ +async function framedWindow (world) { + const handle = await world.browserPage.locator('#frame').elementHandle() + return handle.contentFrame() +} + +/** Where an element of the page under review is on screen. The canvas is + CSS-scaled, so aiming the mouse at anything means measuring it first. */ +async function framedBox (world, selector) { + const target = world.browserPage.frameLocator('#frame').locator(selector) + await target.waitFor() + const box = await target.boundingBox() + assert.ok(box, `${selector} is on screen`) + return box +} + +When('the reviewer scrolls the framed page to the bottom', async function () { + const framed = await framedWindow(this) + await framed.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await eventually(async () => { + assert.ok(await framed.evaluate(() => window.scrollY) > 0, 'the page did not scroll') + }, 'the page under review scrolls in its own window') +}) + +When('the reviewer scrolls the canvas to the bottom', async function () { + const port = this.browserPage.locator('#viewportBox') + await port.evaluate(element => { element.scrollTop = element.scrollHeight }) + await eventually(async () => { + assert.ok(await port.evaluate(element => element.scrollTop) > 0, 'the canvas did not scroll') + }, 'the canvas scrolls') +}) + +When('the reviewer clicks {string} in the framed page and writes {string}', + async function (selector, note) { + const box = await framedBox(this, selector) + await this.browserPage.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + const editor = this.browserPage.locator('#composer textarea.cnote') + await editor.waitFor() + await editor.fill(note) + await editor.press('Enter') + await eventually(() => { this.byNote(note) }, 'the comment reaches the server') + }) + +Then('the comment {string} is anchored to {string}', function (note, id) { + const comment = this.byNote(note) + assert.ok(comment.anchor, `"${note}" attached to an element`) + assert.equal(comment.anchor.id, id, + `it attached to <${comment.anchor.tag}${comment.anchor.id ? ' id=' + comment.anchor.id : ''}>`) +}) + +When('the framed page opens its confirmation dialog', async function () { + await this.browserPage.frameLocator('#frame').locator('#open-confirm').click() + await eventually(async () => { + assert.ok(await this.browserPage.frameLocator('#frame').locator('#confirm').isVisible(), + 'the dialog never opened') + }, 'the dialog opens') +}) + +When('the framed page closes its confirmation dialog', async function () { + await this.browserPage.frameLocator('#frame').locator('#confirm-cancel').click() +}) + +Then('the dialog is where the reviewer is looking', async function () { + await eventually(async () => { + const dialog = await framedBox(this, '#confirm') + const port = await this.browserPage.locator('#viewportBox').boundingBox() + const bottom = port.y + port.height + assert.ok(dialog.y >= port.y - 2 && dialog.y + dialog.height <= bottom + 2, + `the dialog runs ${Math.round(dialog.y)}–${Math.round(dialog.y + dialog.height)} ` + + `and the canvas shows ${Math.round(port.y)}–${Math.round(bottom)}`) + }, 'the dialog is on screen') +}) + +Then('the canvas fits the whole page again', async function () { + await eventually(async () => { + const framed = await framedWindow(this) + const pageHeight = await framed.evaluate(() => document.documentElement.scrollHeight) + const frameHeight = await this.browserPage.locator('#frame').evaluate(el => el.offsetHeight) + assert.ok(Math.abs(frameHeight - pageHeight) <= 4, + `the frame is ${frameHeight}px for a ${pageHeight}px page`) + }, 'the frame goes back to the height of the page') +}) + +When('the reviewer opens Clear all', async function () { + await this.browserPage.locator('#btnClear').click() + await this.browserPage.locator('#clearDialog[open]').waitFor() +}) + +When('the reviewer chooses to clear the open ones too', async function () { + await this.browserPage.locator('#clearOpenToo').check() +}) + +When('the reviewer confirms clearing', async function () { + await this.browserPage.locator('#btnConfirmClear').click() + await this.browserPage.locator('#clearDialog[open]').waitFor({ state: 'detached' }) +}) + +Then('the workspace still shows the comment {string}', async function (note) { + await eventually(async () => { + const { comments } = await this.project() + assert.ok(comments.some(comment => comment.note === note), `"${note}" was taken off`) + }, `"${note}" stays on the list`) +}) + +/* Earlier holds what was closed more than a minute ago, folded away. A comment + the agent closed just now belongs above it, where the reviewer can check it. */ +Then('nothing is folded into Earlier', async function () { + const panel = await this.browserPage.locator('#pbody').innerText() + assert.doesNotMatch(panel, /Earlier/i, `the panel reads:\n${panel}`) +}) + +/* The Addressed group starts folded, so its heading is what says the round + landed — the cards inside it are not in the DOM until it is opened. */ +Then('the workspace shows {int} comment(s) as addressed', async function (n) { + await eventually(async () => { + const panel = await this.browserPage.locator('#pbody').innerText() + assert.match(panel, new RegExp(`Addressed \\(${n}\\)`, 'i'), `the panel reads:\n${panel}`) + }, 'the panel shows the addressed group') +}) + +When('the reviewer clears all comments from the workspace', async function () { + await this.browserPage.locator('#btnClear').click() + const confirm = this.browserPage.locator('#clearDialog #btnConfirmClear') + await confirm.waitFor() + await confirm.click() + await eventually(async () => { + const { comments } = await this.project() + assert.equal(comments.length, 0) + }, 'the list empties') +}) diff --git a/e2e/steps/linking.steps.mjs b/e2e/steps/linking.steps.mjs new file mode 100644 index 0000000..bb075e9 --- /dev/null +++ b/e2e/steps/linking.steps.mjs @@ -0,0 +1,44 @@ +import { Then, When } from '@cucumber/cucumber' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' + +When('the agent arms the stream watcher', function () { + this.startStreamWatcher() +}) + +Then('the watcher asks for a handshake', async function () { + const match = await this.watcherSays(/HANDSHAKE[\s\S]*?--token ([0-9a-f]+)/) + this.handshakeToken = match[1] +}) + +When('the agent answers the handshake', function () { + const run = this.cli('ack', '--all', '--token', this.handshakeToken) + assert.equal(run.status, 0, run.stderr) +}) + +Then('the watcher reports LINKED', async function () { + await this.watcherSays(/LINKED/) +}) + +Then('the agent\'s presence is heartbeated', async function () { + const heartbeat = path.join(this.store, 'watching') + const start = Date.now() + while (!fs.existsSync(heartbeat) && Date.now() - start < 5000) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + assert.ok(fs.existsSync(heartbeat), 'the watching heartbeat is written once acked') +}) + +Then('the watcher receives a REVIEW event', async function () { + await this.watcherSays(/REVIEW/) +}) + +Then('the workspace cannot requeue the round while the watcher lives', async function () { + // Give the delivery a moment to be recorded, then ask the server to requeue: + // rule 15 refuses while a live watcher's heartbeat says the agent holds it. + await this.watcherSays(/REVIEW/) + const requeued = await this.post('/api/comments/requeue', {}) + assert.equal(requeued.response.status, 409, + 'nothing is taken off an agent that is listening') +}) diff --git a/e2e/steps/review.steps.mjs b/e2e/steps/review.steps.mjs new file mode 100644 index 0000000..66da21a --- /dev/null +++ b/e2e/steps/review.steps.mjs @@ -0,0 +1,285 @@ +import { Given, Then, When } from '@cucumber/cucumber' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import * as agent from '../support/mock-agent.mjs' + +/* ── subjects ── */ + +Given('a page is under review', async function () { + await this.startFileReview() +}) + +Given('a page is under review with host {string}', async function (hostId) { + await this.startFileReview(hostId) +}) + +Given('an app is running and under live review', async function () { + await this.startLiveReview() +}) + +/* ── the reviewer's verbs ── */ + +const sendOne = async function (note) { await this.sendComment(note) } + +Given('the reviewer has sent a comment {string}', sendOne) +When('the reviewer sends a comment {string}', sendOne) + +Given('the reviewer has sent comments {string} and {string}', async function (a, b) { + await this.sendComment(a) + await this.sendComment(b) +}) + +const sendOnRoute = async function (note, route) { await this.sendComment(note, { route }) } + +Given('the reviewer has sent a comment {string} on route {string}', sendOnRoute) +When('the reviewer sends a comment {string} on route {string}', sendOnRoute) + +When('the reviewer replies {string} to {string}', async function (text, note) { + const current = this.byNote(note) + await this.post('/api/comments', { + comments: [{ ...current, replies: [{ by: 'reviewer', text, at: new Date().toISOString() }] }], + }) +}) + +When('the reviewer withdraws {string}', async function (note) { + const dismissed = await this.post('/api/comments/dismiss', { id: this.idFor(note) }) + assert.equal(dismissed.response.status, 200) +}) + +When('the reviewer clears all comments', async function () { + this.versionsBeforeClear = this.versionFiles() + const { comments } = await this.project() + for (const comment of comments) { + const dismissed = await this.post('/api/comments/dismiss', { id: comment.id }) + assert.equal(dismissed.response.status, 200, `the server takes ${comment.id} off the list`) + } +}) + +When('the reviewer clears the history', async function () { + const cleared = await this.post('/api/history/clear', {}) + assert.equal(cleared.response.status, 200) +}) + +When('the reviewer hard-resets the review', async function () { + const reset = await this.post('/api/reset', {}) + assert.equal(reset.response.status, 200) +}) + +When('the reviewer approves the design expecting {int} open comments', async function (count) { + const approved = await this.post('/api/approve', { expectedOpenCount: count }) + assert.equal(approved.response.status, 200, 'the sign-off is accepted') +}) + +/* ── the (mock) agent's verbs ── */ + +const delivery = function () { agent.takeDelivery(this) } +Given('the agent has taken delivery', delivery) +When('the agent takes delivery', delivery) + +// One registration per expression: cucumber matches on text, not keyword. +When('the agent closes {string} and publishes {string}', function (note, label) { + const run = agent.closeAndPublish(this, [note], label) + assert.equal(run.status, 0, run.stderr) +}) + +When('the agent closes {string}, publishes {string} and summarises {string}', + function (note, label, summary) { + const run = agent.closeAndPublish(this, [note], label, summary) + assert.equal(run.status, 0, run.stderr) + }) + +When('the agent closes {string} again', function (note) { + agent.closeAndPublish(this, [note]) +}) + +When('the agent replies {string} to {string}', function (text, note) { + agent.reply(this, note, text) +}) + +When('the agent edits the page', function () { this.editPage() }) + +Then('the agent can still close {string}', function (note) { + const run = agent.closeAndPublish(this, [note]) + assert.equal(run.status, 0, run.stderr) +}) + +/* ── deliveries and the brief ── */ + +Then('the delivery names {int} open comment(s), {int} new', function (open, fresh) { + assert.match(this.lastDelivery, new RegExp(`${open} open, ${fresh} new`), + `delivery said:\n${this.lastDelivery}`) +}) + +Then('the brief lists {string} as new', function (note) { + assert.ok(this.brief().includes(`### ${this.idFor(note)} · NEW`), 'the brief marks it new') + assert.ok(this.brief().includes(note), 'the brief carries the note') +}) + +Then('the brief lists {string} as not new', function (note) { + const id = this.idFor(note) + assert.ok(this.brief().includes(`### ${id}`), 'the brief carries the comment') + assert.ok(!this.brief().includes(`### ${id} · NEW`), 'without marking it new') +}) + +Then('the brief carries the reply {string}', function (text) { + assert.ok(this.brief().includes(text), `the brief carries the thread:\n${this.brief()}`) +}) + +Then('the brief names the route {string} on {string}', function (route, note) { + assert.ok(this.brief().includes(note), 'the brief carries the comment') + assert.ok(this.brief().includes(`**Route** \`${route}\``), `the brief names the route:\n${this.brief()}`) +}) + +/* ── comment state ── */ + +Then('the comment {string} has been sent and delivered', function (note) { + const comment = this.byNote(note) + assert.ok(comment.sentAt, 'sent') + assert.ok(comment.deliveredAt, 'delivered') +}) + +Then('the comment {string} is queued, not delivered', function (note) { + const comment = this.byNote(note) + assert.ok(comment.sentAt, 'sent') + assert.equal(comment.deliveredAt, null, 'not delivered') +}) + +Then('the comment {string} is open', function (note) { + assert.equal(this.byNote(note).state, 'open') +}) + +Then('the comment {string} is closed', function (note) { + assert.equal(this.byNote(note).state, 'closed') +}) + +Then('the thread on {string} has an agent reply {string}', function (note, text) { + const replies = this.byNote(note).replies + assert.ok(replies.some(line => line.by === 'agent' && line.text === text), + `the thread reads: ${JSON.stringify(replies)}`) +}) + +Then('nothing is left unanswered', function () { + const run = agent.unanswered(this) + assert.equal(run.status, 0, run.stdout) +}) + +Then('the workspace shows no comments', async function () { + const { comments } = await this.project() + assert.deepEqual(comments, []) +}) + +Then('no record remains of {string}', function (note) { + assert.equal(this.stored().find(item => item.id === this.ids.get(note)), undefined) +}) + +Then('the record of {string} is closed and marked dismissed', function (note) { + const comment = this.byNote(note) + assert.equal(comment.state, 'closed') + assert.ok(comment.dismissedAt) +}) + +Then('the comment {string} is still on the review', async function (note) { + const { comments } = await this.project() + assert.ok(comments.some(item => item.id === this.ids.get(note))) +}) + +/* ── versions ── */ + +Given('the review has reached version {int}', function (version) { + while (this.state().version < version) { + const run = this.cli('publish', ...this.subjectArgs(), '--label', `Version ${this.state().version + 1}`) + assert.equal(run.status, 0, run.stderr) + } +}) + +Then('the review is at version {int}', function (version) { + assert.equal(this.state().version, version) +}) + +Then('the review is still at version {int}', function (version) { + assert.equal(this.state().version, version) +}) + +Then('version {int} is a frozen copy of the page labelled {string}', function (version, label) { + const frozen = fs.readFileSync(path.join(this.versionsDir, `v${version}.html`), 'utf8') + assert.equal(frozen, fs.readFileSync(this.page, 'utf8'), 'the snapshot is the file as published') + const meta = JSON.parse(fs.readFileSync(path.join(this.versionsDir, `v${version}.meta.json`), 'utf8')) + assert.equal(meta.label, label) +}) + +Then('the version history is untouched', function () { + assert.deepEqual(this.versionFiles(), this.versionsBeforeClear) +}) + +Then('only version {int} remains on the timeline', function (version) { + assert.deepEqual(this.versionFiles(), [`v${version}.html`, `v${version}.meta.json`]) +}) + +Then('the review starts again at version {int}', function (version) { + assert.equal(this.state().version, version) + assert.deepEqual(this.versionFiles(), [`v${version}.html`, `v${version}.meta.json`]) +}) + +Then('the page keeps the agent\'s edits', function () { + assert.ok(this.pageHasEdit(), 'nothing the agent changed is undone') +}) + +Then('no version file was frozen', function () { + assert.ok(!this.versionFiles().some(file => file.endsWith('.html')), + `nothing is snapshotted for a live review: ${this.versionFiles()}`) +}) + +/* ── requeue, approve, liveness ── */ + +Given('the agent\'s watching heartbeat is fresh', function () { + fs.writeFileSync(path.join(this.store, 'watching'), String(Date.now())) +}) + +Given('nothing is listening', function () { + fs.rmSync(path.join(this.store, 'watching'), { force: true }) +}) + +When('the workspace asks to requeue', async function () { + this.requeue = await this.post('/api/comments/requeue', {}) +}) + +Then('the server refuses the requeue', function () { + assert.equal(this.requeue.response.status, 409, 'nothing is taken off an agent that is listening') +}) + +Then('the approval records {int} open comments', function (count) { + const approved = JSON.parse(fs.readFileSync(path.join(this.store, 'approved'), 'utf8')) + assert.equal(approved.openComments.length, count) +}) + +Then('the server exits on its own', async function () { + assert.ok(await this.waitForServerExit(), 'sign-off closes the server') +}) + +Then('the command succeeds', function () { + assert.equal(this.lastRun.status, 0, this.lastRun.stderr) +}) + +/* ── host profile ── */ + +Then('the workspace injects the {string} profile named {string}', async function (hostId, name) { + const workspace = await (await fetch(this.origin + this.base + '/')).text() + assert.ok(workspace.includes(`window.__VSTACK_HOST__={"id":"${hostId}","name":"${name}"`), + 'the profile is stamped into the page') +}) + +Then('the injected share capability is {string}', async function (share) { + const workspace = await (await fetch(this.origin + this.base + '/')).text() + const injected = workspace.match(/window\.__VSTACK_HOST__=(\{.*?\})<\/script>/s) + assert.ok(injected, 'the profile is on the page') + assert.equal(JSON.parse(injected[1]).capabilities.share, share) +}) + +/* ── the app under a live review ── */ + +Then('the app is untouched', async function () { + const response = await fetch(this.appOrigin + '/') + assert.equal(response.status, 200) + assert.match(await response.text(), /Fixture/) +}) diff --git a/e2e/support/browser.mjs b/e2e/support/browser.mjs new file mode 100644 index 0000000..c42669a --- /dev/null +++ b/e2e/support/browser.mjs @@ -0,0 +1,23 @@ +import { After, AfterAll, Before } from '@cucumber/cucumber' +import { chromium } from 'playwright' + +/* One Chromium for the whole run; each @browser scenario gets its own page. + HEADED=1 opens a visible browser, slowed enough to watch — for debugging a + scenario or seeing the workspace being driven. */ +let browser = null + +Before({ tags: '@browser' }, async function () { + browser ??= await chromium.launch( + process.env.HEADED ? { headless: false, slowMo: 400 } : {}, + ) + this.browserPage = await browser.newPage({ viewport: { width: 1600, height: 1000 } }) +}) + +After({ tags: '@browser' }, async function () { + await this.browserPage?.close() +}) + +AfterAll(async function () { + await browser?.close() + browser = null +}) diff --git a/e2e/support/mock-agent.mjs b/e2e/support/mock-agent.mjs new file mode 100644 index 0000000..c83f6f8 --- /dev/null +++ b/e2e/support/mock-agent.mjs @@ -0,0 +1,42 @@ +/* + * The agent, mocked. The protocol never sees a model: the agent's whole + * surface is the CLI plus edits to the page, so these functions are a complete + * stand-in for Claude or Codex. Only the @agent-tagged scenarios put a real + * session behind the loop. + */ +import assert from 'node:assert/strict' + +/** One tick: block until there is something to hand over, take it, and exit. */ +export function takeDelivery (world) { + const run = world.cli('watch', ...world.subjectArgs()) + assert.match(run.stdout, /REVIEW/, `expected a delivery, got:\n${run.stdout}${run.stderr}`) + world.lastDelivery = run.stdout + return run.stdout +} + +export function closeAndPublish (world, notes, label, summary) { + const ids = notes.map(note => world.idFor(note)).join(',') + const args = ['publish', ...world.subjectArgs(), '--close', ids] + if (label) args.push('--label', label) + if (summary) args.push('--summary', summary) + return world.cli(...args) +} + +/** A question the reviewer answers by picking, with one option recommended. */ +export function askWithOptions (world, note, text, options, recommend) { + const argv = ['reply', ...world.subjectArgs(), '--comment', world.idFor(note), '--text', text] + for (const option of options) argv.push('--option', option) + if (recommend) argv.push('--recommend', String(recommend)) + const run = world.cli(...argv) + assert.equal(run.status, 0, run.stderr) + return run +} + +export function reply (world, note, text) { + const run = world.cli('reply', ...world.subjectArgs(), '--comment', world.idFor(note), '--text', text) + assert.equal(run.status, 0, run.stderr) +} + +export function unanswered (world) { + return world.cli('unanswered', '--all') +} diff --git a/e2e/support/world.mjs b/e2e/support/world.mjs new file mode 100644 index 0000000..2bb70d6 --- /dev/null +++ b/e2e/support/world.mjs @@ -0,0 +1,217 @@ +import { After, setDefaultTimeout, setWorldConstructor } from '@cucumber/cucumber' +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const SERVER = path.resolve(HERE, '../../plugins/vstack/skills/review/assets/review-server.mjs') + +const PAGE_V1 = 'Review e2e page

Todo

' +const EDIT_MARKER = 'Todo — edited' + +/* Two shapes of long page, because the canvas treats them differently and each + one hid a bug. A page tall in pixels lets the frame grow to the whole + document, so the canvas does the scrolling. A page sized in viewport units + grows with the frame, so the fit gives up and the page keeps its own + scrollbar — and then the frame's scroll and the canvas's disagree. */ +const TAIL = '

The last thing on the page

' +const PAGE_TALL = `Review e2e page +

Todo

+ ${TAIL}
+

Delete everything?

+
+` +const PAGE_SELF_SCROLL = `Review e2e page +

Todo

${TAIL}
` + +export const PAGES = { tall: PAGE_TALL, selfScroll: PAGE_SELF_SCROLL } + +/* Scenarios run serially; each takes a pair of ports (review server + fixture + app) so a slow teardown can never collide with the next scenario. */ +let portCursor = 21000 + (process.pid % 400) * 20 + +export class ReviewWorld { + constructor () { + this.hostId = process.env.VSTACK_HOST || 'claude' + this.temp = fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-e2e-')) + this.port = (portCursor += 2) + this.live = false + this.server = null + this.serverLog = '' + this.app = null + this.ids = new Map() + this.nextId = 0 + this.lastDelivery = '' + this.lastRun = null + } + + get origin () { return `http://127.0.0.1:${this.port}` } + get base () { return this.live ? '/__review' : '' } + get store () { return path.join(this.temp, '.vstack', 'local', 'review', this.name) } + get versionsDir () { return path.join(this.store, 'versions') } + + subjectArgs () { return this.live ? ['--name', this.name] : ['--file', this.page] } + + cli (...argv) { + const run = spawnSync(process.execPath, [SERVER, ...argv], { + encoding: 'utf8', cwd: this.temp, timeout: 30_000, + }) + this.lastRun = run + return run + } + + async startFileReview (hostId = this.hostId, html = PAGE_V1) { + this.live = false + this.name = 'page' + this.page = path.join(this.temp, 'page.html') + fs.writeFileSync(this.page, html) + const published = this.cli('publish', ...this.subjectArgs(), '--label', 'Initial version') + assert.equal(published.status, 0, published.stderr) + await this.serve(['--file', this.page], hostId) + } + + async startLiveReview () { + this.live = true + this.name = 'testapp' + const appPort = this.port + 1 + this.app = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(`Fixture

${req.url}

Settings`) + }) + await new Promise(resolve => this.app.listen(appPort, '127.0.0.1', resolve)) + this.appOrigin = `http://127.0.0.1:${appPort}` + await this.serve(['--app', this.appOrigin, '--name', this.name], this.hostId) + } + + async serve (subject, hostId) { + this.server = spawn(process.execPath, [ + SERVER, 'serve', ...subject, '--port', String(this.port), + '--idle-timeout', '0', '--host', hostId, '--no-open', + ], { cwd: this.temp, stdio: ['ignore', 'pipe', 'pipe'] }) + this.server.stdout.on('data', chunk => { this.serverLog += chunk }) + this.server.stderr.on('data', chunk => { this.serverLog += chunk }) + for (let attempt = 0; attempt < 100; attempt++) { + try { + if ((await fetch(this.origin + this.base + '/api/project')).ok) return + } catch {} + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`review server did not start:\n${this.serverLog}`) + } + + async post (pathname, body) { + const response = await fetch(this.origin + this.base + pathname, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return { response, body: await response.json().catch(() => null) } + } + + async project () { + const response = await fetch(this.origin + this.base + '/api/project') + assert.ok(response.ok, 'the workspace API answers') + return response.json() + } + + idFor (note) { + if (!this.ids.has(note)) this.ids.set(note, `c${++this.nextId}`) + return this.ids.get(note) + } + + /** What the workspace saves for one comment. `sentAt` set means "let go of it". */ + commentPayload (note, extra = {}) { + return { id: this.idFor(note), kind: 'area', note, size: 'desktop', replies: [], ...extra } + } + + async sendComment (note, extra = {}) { + const saved = await this.post('/api/comments', { + comments: [this.commentPayload(note, { sentAt: new Date().toISOString(), ...extra })], + }) + assert.equal(saved.response.status, 200, 'the workspace save is accepted') + } + + stored () { + const file = path.join(this.store, 'comments.json') + if (!fs.existsSync(file)) return [] + return JSON.parse(fs.readFileSync(file, 'utf8')).comments + } + + /** A comment made through the workspace mints its own id, so fall back to + the note's words and remember what the workspace called it. */ + byNote (note) { + let comment = this.stored().find(item => item.id === this.ids.get(note)) + comment ??= this.stored().find(item => item.note === note) + assert.ok(comment, `comment "${note}" exists on the review`) + this.ids.set(note, comment.id) + return comment + } + + brief () { return fs.readFileSync(path.join(this.store, 'brief.md'), 'utf8') } + + state () { return JSON.parse(fs.readFileSync(path.join(this.store, 'state.json'), 'utf8')) } + + versionFiles () { + if (!fs.existsSync(this.versionsDir)) return [] + return fs.readdirSync(this.versionsDir).sort() + } + + editPage () { + fs.writeFileSync(this.page, fs.readFileSync(this.page, 'utf8').replace('Todo', EDIT_MARKER)) + } + + pageHasEdit () { return fs.readFileSync(this.page, 'utf8').includes(EDIT_MARKER) } + + /** The long-lived watcher a real session runs, its stdout kept as one + growing transcript so steps can wait for the next protocol line. */ + startStreamWatcher () { + this.watcher = spawn(process.execPath, [SERVER, 'watch', '--all', '--stream'], { + cwd: this.temp, stdio: ['ignore', 'pipe', 'pipe'], + }) + this.watcherOut = '' + this.watcher.stdout.on('data', chunk => { this.watcherOut += chunk }) + this.watcher.stderr.on('data', chunk => { this.watcherOut += chunk }) + } + + async watcherSays (pattern, timeout = 10_000) { + const start = Date.now() + while (Date.now() - start < timeout) { + const match = this.watcherOut.match(pattern) + if (match) return match + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`the watcher never said ${pattern}:\n${this.watcherOut}`) + } + + async waitForServerExit () { + for (let attempt = 0; attempt < 50 && this.server.exitCode === null; attempt++) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + return this.server.exitCode !== null + } + + teardown () { + this.watcher?.kill('SIGTERM') + this.server?.kill('SIGTERM') + this.app?.close() + fs.rmSync(this.temp, { recursive: true, force: true }) + } +} + +setWorldConstructor(ReviewWorld) +setDefaultTimeout(60_000) + +After(function () { this.teardown() }) diff --git a/e2e/test-plan.html b/e2e/test-plan.html new file mode 100644 index 0000000..6b696e8 --- /dev/null +++ b/e2e/test-plan.html @@ -0,0 +1,330 @@ + + + + + +Review e2e test plan + + + +
+
+

Review e2e test plan

+

End-to-end scenarios for the review loop, written as Gherkin so they + become the automated suite verbatim. Comment on any scenario: strike what should not + exist, comment where the wording or expected behaviour is wrong.

+
+ @round1 automate first — driven headlessly through the server API and CLI + @later needs a real browser (Playwright) — a later pass +
+
+ +
+

Feature: Page review — sending comments

+ +
+
S1

Send with no round in flight — delivered immediately

@round1
+
Background: + Given "todo.html" is served for review and the agent's watcher is linked + +Scenario: a sent comment reaches the agent straight away + Given no comment is currently with the agent + When the reviewer adds a comment "Make the title bigger" and presses Send + Then the watcher receives a REVIEW event naming 1 open comment + And the brief lists the comment, marked new, with its element anchor + And the comment has sentAt and deliveredAt set
+
+ +
+
S2

Send while a round is in flight — queued, picked up after the round

@round1
+
Scenario: a comment sent mid-round waits for the next delivery + Given the agent has taken delivery of comment A and has not yet answered it + When the reviewer adds comment B and presses Send + Then comment B is queued: sentAt set, deliveredAt empty + And no new REVIEW event interrupts the round in flight + When the agent publishes v2 closing comment A + Then the next delivery hands over comment B, marked new
+

Why: the contract promises the agent is never interrupted mid-round; queued comments accumulate and arrive with the next tick.

+
+ +
+
S3

Whatever is not closed comes back

@round1
+
Scenario: an unclosed comment returns on the next delivery + Given the agent was handed comments A and B in one delivery + When the agent publishes closing only A + Then B stays open + When the reviewer sends a new comment C + Then the delivery hands over B and C — B marked not new, C marked new
+
+
+ +
+

Feature: Page review — closing, versions, threads

+ +
+
S4

Publish closes comments and snapshots a version

@round1
+
Scenario: publish --close moves comments to Addressed and freezes v2 + Given the agent holds comment A and has edited todo.html + When the agent runs publish --close A --label "Bigger title" + Then versions/v2.html is a frozen copy of the file and v2.meta.json carries the label + And comment A is closed, with closedAt set + And the workspace offers "v2 is ready — Review changes" + And A appears in the Addressed section offering Revert and Refine
+
+ +
+
S5

An agent reply asks a question and keeps the comment open

@round1
+
Scenario: replying never changes a comment's state + Given the agent holds an ambiguous comment A + When the agent replies "Every row, or only overdue ones?" + Then the reply is appended to A's thread with by: "agent" + And A stays open + And `unanswered` no longer names A — a reply answers the round
+
+ +
+
S6

A reviewer reply to a closed comment reopens it

@round1
+
Scenario: Refine / a reply sends an addressed comment back + Given comment A is closed + When the reviewer replies "Not quite — it needs to be bolder too" and presses Send + Then A is open again + And the next brief carries A with reopened: true and the full thread
+
+
+ +
+

Feature: Page review — clearing

+ +
+
S7

Clear all comments

@round1
+
Scenario: Clear all empties the list without touching versions + Given comment A is with the agent, B is queued, and C is addressed + When the reviewer confirms "Clear all" in the comment list footer + Then the workspace shows no comments at all + And B, never delivered, leaves no record + And A keeps a record marked dismissed and closed + And the version history is untouched + And the agent is not interrupted and publish --close A still succeeds
+
+ +
+
S8

Clear history

@round1
+
Scenario: Clear history deletes past versions and keeps the present + Given versions v1, v2 and v3 exist + When the reviewer confirms "Clear history" + Then only the current version remains on the timeline + And the comment list is untouched + And the served page is unchanged
+
+ +
+
S9

Hard reset

@round1
+
Scenario: Hard reset restarts the review at v1 + Given the review has 3 comments and 3 versions + When the reviewer confirms "Hard reset Visual Stack" in the settings menu + Then every comment and every version is deleted + And the review is at v1 again + And todo.html keeps every edit the agent made — nothing is undone
+
+
+ +
+

Feature: Live app review (--app)

+ +
+
S10

Same send and queue flow, comments carry a route

@round1
+
Background: + Given a small test app runs on localhost and is served with --app --name testapp + And the agent's watcher is linked + +Scenario: a live comment names the screen it was made on + When the reviewer comments on the /settings screen and presses Send + Then the delivery works exactly as S1 + And the comment carries route: "/settings" + And the brief names the Route under the comment
+
+ +
+
S11

A live publish is a marker, not a file snapshot

@round1
+
Scenario: publish --name closes comments without freezing a file + Given the agent holds a live comment A + When the agent runs publish --name testapp --close A --label "Fixed spacing" + Then comment A is closed + And no source file of the app is copied into the store + And the timeline still scrubs: each Send captured the DOM the reviewer was looking at
+

Why: in a live review the app is the truth — a version records what was finished, and history shows what the reviewer saw when they said it.

+
+ +
+
S12

Hard reset in a live review deletes comments only

@round1
+
Scenario: resetting a live review leaves the app alone + Given a live review with comments and captures + When the reviewer confirms the hard reset + Then every comment is deleted and the review starts again + And the app and its source are exactly as they were
+
+
+ +
+

Feature: Edge cases

+ +
+
S13

Withdrawing a delivered comment never blocks the agent

@round1
+
Scenario: the agent finishes what it was handed + Given the agent has taken delivery of comment A + When the reviewer deletes A from the workspace + Then A leaves the reviewer's list, marked dismissed + And the agent is not notified + And publish --close A still succeeds
+

Why: rule 4 of the contract — nothing can refuse a close. Every past dead-end came from a rule that could stop a round ending.

+
+ +
+
S14

A stalled round can be sent again — but only once nothing is listening

@round1
+
Scenario: requeue is refused while the agent is alive + Given the agent holds comment A and its watching heartbeat is fresh + When the workspace asks to requeue A + Then the server refuses — the agent still has it + +Scenario: requeue rescues a dead session's round + Given the agent session died and the heartbeat has gone stale + When the reviewer presses "Send again" + Then A's deliveredAt is cleared and A is queued again + And the next watcher to link is handed A
+
+ +
+
S15

Approve ends the review deliberately

@round1
+
Scenario: sign-off with comments still open + Given 2 comments are still open + When the reviewer confirms "Approve & finish" past the warning naming those 2 + Then the watcher receives APPROVED with openComments: 2 + And the server exits on its own
+
+ +
+
S16

Closing twice is a no-op

@round1
+
Scenario: a retried publish is safe + Given comment A is already closed + When the agent runs publish --close A again + Then the command succeeds and nothing changes
+
+ +
+
S17

Pins, drag marks and dialogs in a real browser

@later
+
Scenario outline: the browser-only surface (deferred to the Playwright pass) + - a click drops a pin and the note opens on the canvas + - an empty note is discarded on dismiss + - Move and Delete marks arrive as kind: move / strike + - View mode hides every annotation; esc toggles back + - Clear all and Hard reset sit behind their confirm dialogs + - the link dot flips between Linked and Unlinked with the watcher
+

Why: round 1 drives the same HTTP endpoints the workspace calls, which proves the protocol but not the pointer handling. This scenario is the placeholder for the browser pass.

+
+
+ +
+

Feature: Host matrix — Claude and Codex

+ +
+
S18

Every protocol scenario runs under both hosts

@round1
+
Scenario outline: the loop behaves identically whichever host serves it + Given the server is started with --host <host> + Then S1–S16 pass unchanged + And the workspace page injects the <host> profile as window.__VSTACK_HOST__ + And the Send button is labelled "Send to <name>" + + Examples: + | host | name | + | claude | Claude | + | codex | Codex |
+

Why: the protocol is host-independent by contract; the suite proves it by running the whole matrix twice, selected by VSTACK_HOST.

+
+ +
+
S19

Capabilities differ where the profiles say they do

@round1
+
Scenario: share is offered only where the host can publish + Given the server is started with --host claude + Then the workspace offers "Publish a link to this wireframe" (share: artifact) + Given the server is started with --host codex + Then that control is hidden (share: copy)
+
+
+ +
+

Feature: A real agent in the loop

+ +
+
S20

One smoke round per host, on the cheapest model

@later
+
Scenario outline: a real headless session answers a real comment + Given a review is served and a headless <host> session is started + on the cheapest model, with the vstack plugin loaded + When the reviewer sends "Change the heading to 'Hello'" + Then within the timeout the session takes delivery, edits the page, + and publishes v2 closing the comment + And `unanswered` exits 0 + + Examples: + | host | session command | + | claude | claude -p --model haiku … | + | codex | codex exec --model <cheapest> … |
+

Why: S1–S19 simulate the agent with the CLI, which proves the protocol but not the skill instructions. One paid round per host proves the whole loop. It runs only when explicitly asked (tagged, needs API keys, costs money) — never in ordinary CI. The model is pinned to the cheapest each CLI offers, in one place in the suite config.

+
+
+ +
+

Automation approach — comment here too

+
    +
  • Framework: cucumber-js (@cucumber/cucumber). The features above become e2e/features/*.feature verbatim; step definitions live in e2e/steps/.
  • +
  • Where it lives: e2e/ at the repo root with its own package.json, outside plugins/ — the plugin itself stays dependency-free, like the demo-recording setup.
  • +
  • How round 1 drives it: no browser. Reviewer verbs go through the same HTTP API the workspace uses (/api/comments, /api/reset, /api/history/clear, /api/approve); agent verbs through the real CLI (watch --stream, publish, reply, ack, unanswered). Each scenario gets a fresh temp directory and its own port.
  • +
  • Claude and Codex are mocked: e2e/support/mock-agent.mjs plays the agent role — it answers the handshake, takes deliveries, makes a deterministic edit, and replies or closes exactly as the scenario directs. The protocol never sees a model, so the mock is a complete stand-in for either host; no API keys, no cost. Only S20 puts a real session behind the loop, to prove the skill instructions themselves.
  • +
  • Live app fixture: a ~20-line stdlib Node server with two routes, started per scenario.
  • +
  • Host matrix: CI runs the suite twice, VSTACK_HOST=claude and VSTACK_HOST=codex; S18–S19 assert the differences, everything else asserts sameness.
  • +
  • Real-agent tier: S20 is tagged @agent and excluded by default. It runs only on demand, with the model pinned to the cheapest per CLI (Claude: Haiku; Codex: set in config) so a smoke round costs cents.
  • +
  • CI: one new line in ci.yml running npm ci && npx cucumber-js inside e2e/ (the @agent tag stays excluded).
  • +
+
+
+ + diff --git a/plugins/vstack/.claude-plugin/plugin.json b/plugins/vstack/.claude-plugin/plugin.json index f2be26c..12e92ac 100644 --- a/plugins/vstack/.claude-plugin/plugin.json +++ b/plugins/vstack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vstack", "displayName": "Visual Stack", - "version": "5.0.0", + "version": "6.4.0", "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", diff --git a/plugins/vstack/.codex-plugin/plugin.json b/plugins/vstack/.codex-plugin/plugin.json index b6e72da..87767f4 100644 --- a/plugins/vstack/.codex-plugin/plugin.json +++ b/plugins/vstack/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vstack", - "version": "5.0.0", + "version": "6.4.0", "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", diff --git a/plugins/vstack/contracts/host.md b/plugins/vstack/contracts/host.md index 5454219..b45e6d9 100644 --- a/plugins/vstack/contracts/host.md +++ b/plugins/vstack/contracts/host.md @@ -104,8 +104,18 @@ When serving a workspace, the server: The workspace uses `name` (and related strings) for chrome. It never hardcodes a product name. -Update banners use `install` from the same profile when the Host supports -update detection (`capabilities.updateDetect`). +The server also checks whether a newer release exists, and hands the answer to +the page. `capabilities.updateDetect` says where an installed copy is found: + +| `capabilities.updateDetect` | Where the installed version comes from | +| --- | --- | +| `claude-install` | Claude Code's record in `~/.claude/plugins/installed_plugins.json` | +| `codex-install` | The version directory Codex unpacked the copy into, under `~/.codex/plugins/cache/` | +| `none` | Nowhere. No check runs and no banner appears | + +A Host that is not `none` must also give `install.commands`, because the banner +shows the reader how to take the update. A copy running from a clone matches no +install, and never produces a banner. --- diff --git a/plugins/vstack/contracts/host.schema.json b/plugins/vstack/contracts/host.schema.json index 4cbbfec..c610de1 100644 --- a/plugins/vstack/contracts/host.schema.json +++ b/plugins/vstack/contracts/host.schema.json @@ -37,8 +37,8 @@ }, "updateDetect": { "type": "string", - "enum": ["claude-install", "none"], - "description": "How to detect an installed copy for update banners." + "enum": ["claude-install", "codex-install", "none"], + "description": "Where to look for an installed copy when deciding whether to show an update banner. none skips the check." } } }, diff --git a/plugins/vstack/contracts/review-loop.md b/plugins/vstack/contracts/review-loop.md index d92c4a3..c141b30 100644 --- a/plugins/vstack/contracts/review-loop.md +++ b/plugins/vstack/contracts/review-loop.md @@ -5,69 +5,82 @@ workspace), (2) the **agent session**, and (3) the **reviewer** in the browser. Host-independent: any Host that fulfills [host.md](host.md) can drive this loop. +One list of comments. Each is open or closed. The agent is the only one who +calls a comment done. Everything the workspace shows is derived from that list. + --- ## Participants | Role | Responsibility | | --- | --- | -| **Engine** | Serves workspace, stores state, freezes versions, emits events | -| **Agent** | Applies feedback, publishes versions, replies, fulfills Host ops | +| **Engine** | Serves workspace, keeps the comment list, freezes versions, emits events | +| **Agent** | Takes delivery, applies comments, replies, closes, publishes versions | | **Reviewer** | Comments in the browser; Send / Approve / Share | --- ## Subject under review -| Mode | Identity | What a round changes | +| Mode | Identity | What the agent changes | | --- | --- | --- | | **File** | `--file ` | That HTML file | | **Live** | `--app ` + `--name ` | App source (or notes for a third-party site) | --- -## On-disk store - -Beside the file: `/.vstack/local/review//` -Live (no file): `/.vstack/local/review//` +## The comment -`review/` is where a store is **created**. An implementation must also **read** -`/.vstack/local/wireframe//`, which is where stores made before this -tool was renamed still are — first directory holding the subject wins, and a -subject present in both is read from `review/`. Nothing is migrated: a user's -rounds stay where they were written. `status` reports the resolved `store`, so -a caller never has to pick between the two itself. +`comments.json` is the whole truth for a review. One list, not one per version. -| Path | Role | +| Field | Meaning | | --- | --- | -| `state.json` | `{ name, version, app?, … }` | -| `versions/v.html` | Frozen file or DOM capture | -| `versions/v.meta.json` | Label, date, addressed ids | -| `reviews/v/annotations.json` | Live comments + threads | -| `reviews/v/feedback.md` | Markdown brief for the agent | -| `reviews/v/feedback.json` | Same, structured | -| `rounds/r.json` | Durable membership, revisions, outcomes, and completion record | -| `handshake` | A stream watcher waiting to be told its events are being read. Carries the token it printed; `ack` marks the record answered rather than deleting it, and only the watcher whose token it holds acts on it and clears it | -| `pending` | Notification only: review sent, agent must `claim` it | -| `approved` | Sentinel: design signed off; engine shutting down | -| `share` | Sentinel: reviewer wants a shareable link | -| `url` | Present only while `serve` is running | -| `watching` | Heartbeat while Host op `watch_stream` is active | +| `id` | Stable, shared with the workspace | +| `note` | What the reviewer asked for. Frozen once `sentAt` is set | +| `kind` | `comment` · `area` · `general` · `move` · `strike` | +| `anchor` | Element identity (tag, id, cls, role, label, text, region, sel) | +| `move` / `strike` / `covers` | Payload for the drawn marks | +| `route` | Live only — the app path it was made on | +| `size` | Screen size it was made at | +| `seenAt` | Version on screen when it was written. Display only | +| `state` | `open` · `closed` | +| `closedAt` | When the agent closed it | +| `replies` | `{ by, text, at }[]`, append-only. An agent reply may carry `options: [{ text, recommended }]` — answers the reviewer picks from | +| `sentAt` | The reviewer let go of it. Null means it is still a draft | +| `deliveredAt` | The agent was last handed it. Null means it is still queued here | +| `deliveredTo` | The session the last delivery was recorded for — the `--session` id its watcher was started with. Null when the watcher carried no identity | +| `dismissedAt` | The reviewer took it off the list after it had been delivered. The record stays; the workspace never shows it again | + +Those two timestamps carry the whole of a comment's progress: + +| State | `sentAt` | `deliveredAt` | Editable | Withdrawable | +| --- | --- | --- | --- | --- | +| Being written | — | — | yes | yes, the record goes | +| Queued | set | — | no | yes, the record goes | +| With the agent | set | set | no | yes, the record stays behind it | -`serve` also records the store it is serving under the directory it was run -from: `/.vstack/local/review/.serving/`, one file per live review, -holding that review's store path. It is written after `url` and removed with it. +--- -`watch --all` finds a review by walking the directory it was run from **and** by -following those pointers. The pointer is what covers a page that lives outside -that directory, whose store lives outside it too. A pointer whose store has no -`url` is stale, and the reader deletes it. +## Who may do what + +**Reviewer** — three verbs: + +- Add a comment. +- Reply to a comment. A reply to a closed comment reopens it. Their own reply, + while it is still the thread's last line, may be taken back (rule 7). +- Withdraw a comment, at any point. -Every vstack tool keeps its per-machine working files under -`.vstack/local//`, resolved by `lib/workdir.mjs`: the enclosing `.vstack` -when the artifact already sits in one, otherwise the one beside it. Engines must -go through that helper rather than joining the path themselves. `local/` is -gitignored whole; the rest of `.vstack/` is the pipeline and is committed. +A reviewer never closes a comment as done. Withdrawing takes it off the list +they are working from: a comment the agent has not been handed leaves no record +at all, and one it has been handed keeps a record marked `dismissedAt` and +`closed`, so the agent can still close what it was given. The agent is not +interrupted by a withdrawal and is not told of one. + +**Agent** — three verbs: + +- Take delivery of the open comments (the tick). +- Reply to a comment. This never changes its state. +- Close comments, and optionally snapshot a version. --- @@ -84,6 +97,44 @@ CSS/UI may use class `agent`; class `claude` remains a synonym for old markup. --- +## On-disk store + +Beside the file: `/.vstack/local/review//` +Live (no file): `/.vstack/local/review//` + +`review/` is where a store is **created**. An implementation must also **read** +`/.vstack/local/wireframe//`, which is where stores made before this +tool was renamed still are — first directory holding the subject wins, and a +subject present in both is read from `review/`. + +| Path | Role | +| --- | --- | +| `state.json` | `{ name, version, file? \| app?, start? }` | +| `comments.json` | Every comment for this review | +| `brief.md` | The open comments, rewritten on every delivery | +| `versions/v.html` | Frozen file, or the DOM capture for a live app | +| `versions/v.meta.json` | `{ n, label, date }` | +| `reviews/v/` | Only ever read: where a store filled by an older version keeps its comments | +| `handshake` | A stream watcher waiting to be told its events are being read | +| `approved` | Sentinel: design signed off; engine shutting down | +| `share` | Sentinel: reviewer wants a shareable link | +| `url` | Present only while `serve` is running | +| `watching` | Heartbeat while Host op `watch_stream` is active | + +`serve` also records the store it is serving under the directory it was run +from: `/.vstack/local/review/.serving/`, one file per live review. +`watch --all` finds a review by walking the directory it was run from **and** by +following those pointers. A pointer whose store has no `url` is stale, and the +reader deletes it. + +**Reading a store from an older version.** When `comments.json` is absent, build +it from the `reviews/v/` directories: the newest copy of each id wins, +`addressed` and a reviewer's dismissal both become `closed`, and anything +already sent counts as delivered. Those files are left where they are. Nothing +is migrated behind the user's back. + +--- + ## CLI surface All commands: `node review-server.mjs …` @@ -91,15 +142,15 @@ Host selection: `--host ` or `VSTACK_HOST=` (affects UI injection only). | Command | Contract | | --- | --- | -| `serve --file …` / `serve --app …` | Long-lived via Host `background`. Binds `127.0.0.1`. | -| `ack --file/name … --token ` \| `ack --all --token ` | Answer a stream watcher's handshake. Only this arms the `watching` heartbeat | -| `claim --file/name … --round r` | Acknowledge delivery while preserving the durable round ledger | -| `publish --file/name … --round r --label … [--addressed ids]` | Validate full round coverage, freeze next version, and mark comments addressed | -| `reply --file/name … --round r --comment --text "…"` | Append `{ by: "agent", text, at }`; status → `question` | -| `share --file/name … --url ` | Record public URL; clear `share` sentinel | -| `check --file/name …` | Always exits `0`. Names a queued round nobody has claimed | +| `serve --file …` / `serve --app …` | Long-lived via Host `background`. Binds `127.0.0.1` | +| `watch [--all] [--file …] [--stream] [--session ]` | Take delivery. Blocks until the reviewer has said something new. `--session` names the agent session each delivery binds to | +| `ack --file/name … --token ` | Answer a stream watcher's handshake. Only this arms the `watching` heartbeat | +| `publish --file/name … [--close ids] [--label …] [--summary …]` | Close comments, snapshot a version, or both. `--summary` records the account of the round, which the workspace shows; the latest one is kept and a publish without it clears it | +| `reply --file/name … --comment --text "…" [--option "…" … --recommend ]` | Append `{ by: "agent", text, at }`, with `options: [{ text, recommended }]` when options are given. The reviewer answers by pressing one, which posts those words as their reply | +| `share --file/name … --url ` | Record public URL; clear the `share` sentinel | | `status --file/name …` | Human/debug snapshot | -| `watch [--all] [--file …] --stream` | Event stream via Host `watch_stream` | +| `unanswered [--all] [--file/name …] [--session ]` | Comments the agent was handed and has not answered. Exits 1 while any remain. With `--session`, only deliveries recorded for that id count | +| `reset --file/name …` | Delete every comment and version for the review, and start again at v1 | --- @@ -112,28 +163,30 @@ One line of stdout per event (from `watch --stream`): | `WATCHING` | Stream armed | — | | `HANDSHAKE` | The watcher asking whether anyone receives it | Run the `ack` command it prints, immediately | | `LINKED` | The handshake was answered and at least one review is covered | — | -| `UNLINKED` | The handshake was answered and no review turned up to cover, so no workspace goes Linked | Start it again via `watch_stream` with `--file` if a review is running elsewhere; a later serve in the same directory is picked up without it | +| `UNLINKED` | The handshake was answered and no review turned up to cover | Start it again with `--file` if a review is running elsewhere | | `UNWIRED` | The handshake went unanswered; the watcher exits `3` | Start it again via `watch_stream` | -| `REVIEW` | `pending` written; round id and path to `feedback.md` | `claim` the round, apply brief, publish/reply | -| `REPLIED` | Reviewer answered a question | Continue that comment’s thread | +| `REVIEW` | Comments have been handed over; names how many and the brief | Read `brief.md`, apply it, `publish --close` / `reply` | | `SHARE` | Link requested | Host `share` if capable; then `share --url` | | `APPROVED` | Sign-off; server exiting | Confirm; next pipeline stage as skill says | | `OPENED` | Another live store joined `--all` | — | | `CLOSED` | Tab/store gone | Drop; exit when none left | +A reply raises no event of its own: it is the same comment coming round again +with more said on it. + --- -## Round protocol +## The loop ``` serve (background) + watch_stream │ ▼ -reviewer comments ──Send──► round record + pending + feedback.md +reviewer comments ──Send──► comments.json │ │ - │ REVIEW event + │ REVIEW event ──► brief.md (delivery recorded) │ ▼ - │ agent: claim · apply · reply/check · publish + │ agent: apply · reply/close · publish │ │ │◄──── version ready ─────┘ │ @@ -143,53 +196,29 @@ reviewer comments ──Send──► round record + pending + feedback.md Rules: -1. Only a validated `publish --round … --addressed …` closes comments (reviewer has no resolve). -2. The engine rejects publication unless every round member is addressed, dismissed, or waiting on the reviewer. -3. The engine rejects unknown IDs, changed comment revisions, unclaimed rounds, and stale round IDs. -4. A round in flight cannot be called off. The reviewer's only correction is to send again, which supersedes the brief. Do not delete protocol files manually. -5. Retrying an already completed `publish --round …` is idempotent and creates no extra version. -6. One `watch_stream` per session is enough with `--all`. -7. Presence is proven. A stream watcher writes its `watching` heartbeat from the moment its handshake is answered, so **Linked** means a session is receiving the stream. Default window 120 s (`--handshake-timeout `). -8. Presence is per review, and per watcher. A watcher heartbeats only the stores it covers, and goes live only on an answer carrying its own token — a second watcher's handshake is not an answer to the first. It reports `LINKED` once it covers a review, and `UNLINKED` when none has turned up. -9. Presence is also claim-backed. The engine reports the agent present (workspace **Linked**) only while the `watching` heartbeat is fresh **and** no queued round has sat unclaimed past the claim window (90 s). A stalled round drops presence — a watcher whose events nobody reads must look the same to the reviewer as no watcher at all. - ---- - -## Feedback brief - -`feedback.md` + `feedback.json` carry at least: - -| Field | Meaning | -| --- | --- | -| `id` | Pass to `--addressed` | -| `kind` | `comment` · `area` · `general` · `move` · `strike` | -| `note` | Requirement text. Empty is valid on `move` and `strike` | -| `anchor` | Element identity (tag, id, classes, text, region, selector) | -| `move` | `move` only — `{ target: { …anchor identity, where }, delta }`, `where` is `inside` · `before` · `after` | -| `strike` | `strike` only — `{ scope: 'text' \| 'element', text }` | -| `screenSize` | Layout the comment was made at | -| `route` | Live only — app path | -| `status` | `open` · `question` · `addressed` | -| `replies` | `{ by, text, at }[]` | -| `reopened` / `wantsRevert` | Returned from Refine / Revert | - -A comment carries its requirement in `note`. A `move` and a `strike` carry it in -their own fields instead, so a reader must not treat an empty `note` as an -incomplete comment. `move.target` outranks `move.delta`: the element and side -survive a reflow and the pixel distance does not. - ---- - -## Share - -- **File review:** subject file (self-contained HTML) is what gets a public URL. -- **Live review:** a DOM capture for the current round; agent must say it is a still. -- Offline bundle (`bundle-artifact.mjs`): no session; Send becomes copy-to-clipboard. - ---- - -## Non-goals of this contract - -- How the Host names its tools (see Host adapters). -- Pipeline / `.vstack/pipeline.json` (skill handoff, not the review engine). -- Marketplace install paths (Host profile `install` + `updateDetect` only). +1. Only `publish --close` says a comment is done. The reviewer has no resolve. Withdrawing (rule 9) takes a comment off their list and says nothing about the work. +2. A tick hands over **every** open comment, not only the new ones, and marks which are new since the last delivery. +3. Whatever the agent does not close stays open and comes back on the next tick. There is no coverage to satisfy. +4. **Nothing can refuse a close.** An agent that has taken delivery can always finish, whatever the reviewer did meanwhile. +5. Closing what is already closed is a no-op, so a retried command is safe. +6. A comment's words are frozen when the reviewer sends it. The engine keeps the stored note whatever a client saves afterwards. +7. A reply is append-only in a save, from either role: two copies of a thread merge to the union of both, so a line missing from one copy is never a removal. Removal is a request of its own. The reviewer may take back their own reply while it is the thread's last line; once anything has been said over it, it stays. The agent is not told of a take-back and finishes from whatever it already took delivery of (rule 4). +8. A reviewer's reply to a closed comment reopens it. An agent's reply never changes state. +9. A comment may be withdrawn at any point. Undelivered, it is deleted. Delivered, it is marked `dismissedAt` and `closed`: it leaves the workspace, no tick raises it again, and the id still resolves so the agent holding it can close it. +10. A version is a snapshot to look at. It records no comments, and no comment records a version. +11. One `watch_stream` per session is enough with `--all`. +12. Presence is proven. A stream watcher writes its `watching` heartbeat from the moment its handshake is answered, so **Linked** means a session is receiving the stream. Default window 120 s (`--handshake-timeout `). +13. Presence is per review, and per watcher. A watcher heartbeats only the stores it covers, and goes live only on an answer carrying its own token. +14. An agent that took delivery answers. A comment it was handed is answered by closing it or by replying to it. Neither is a round that stopped halfway, because no tick will raise that comment again until the reviewer writes. `unanswered` names them, and exits 1 while any remain. +15. A delivered comment goes back to the queue when nothing is listening. That is the way out of a round whose agent session died: those comments are not `unseen`, so no new watcher would ever hand them over. `deliveredAt` and `deliveredTo` are cleared and the comment is Queued again. The engine refuses this while a `watching` heartbeat is fresh, because then an agent still holds it and rule 14 applies instead. +16. A delivery binds to a session. A watcher started with `--session ` records that id on every comment it hands over, the latest delivery owns the round, and `unanswered --session ` answers for that session alone — so a Host that gates the end of a turn never holds one session's turn open for another session's round. A delivery recorded with no identity is reported only by the unfiltered form. +17. `watch --all` never covers a store whose `watching` heartbeat is fresh: that heartbeat is another watcher, and covering the review twice would deliver the same comment to two sessions. The store joins the sweep once the heartbeat is gone. A store named with `--file` is covered regardless — naming it is a deliberate takeover. + +Rule 14 is an obligation on the agent, not a refusal by the engine. Rule 3 +still holds: `publish` closes exactly what it names and accepts everything else +being left open. A Host that can gate the end of a turn is where the obligation +is enforced — see the Stop hook in `plugins/vstack/hooks/`. A Host that cannot +gets the rule as an instruction and nothing more. + +Rule 4 is the liveness property. Every dead-end this protocol has had came from +a rule that could stop a round ending. diff --git a/plugins/vstack/experimental/phase-build/assets/build-board.html b/plugins/vstack/experimental/phase-build/assets/build-board.html index f44b6d6..26538bf 100644 --- a/plugins/vstack/experimental/phase-build/assets/build-board.html +++ b/plugins/vstack/experimental/phase-build/assets/build-board.html @@ -4,58 +4,67 @@ /* One palette for every vstack page. Roles, not colours: a page asks for --surface, not for white, so light and dark are the same stylesheet. + The values come from `design/tokens.css`, which owns the palette. They are + copied rather than imported because a page has to work opened off disk and + inlined into an Artifact under a CSP that blocks every external request — + nothing here may be fetched. `tests/design-tokens.mjs` fails when the two + files disagree, so the copy cannot drift quietly. + Page-specific hues (the story map's phase bands, the board's new/have/touch, the spec's priorities) stay in the page, below this block — they mean something only there. Everything here is shared, and is the reason a board and a spec look like the same product. Three states, in this order: the OS preference, then an explicit choice. - `data-theme` absent means auto. */ + `data-theme` absent means auto. + + The type scale is the guide's; the families are not. Space Grotesk and Inter + would each be an external request, so every page reads in the system stack. */ :root{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); - --radius:9px; + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); + --radius:8px; --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color-scheme:light; } @media (prefers-color-scheme:dark){:root{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; }} :root[data-theme=light]{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); color-scheme:light; } :root[data-theme=dark]{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; } /* /vstack:shell tokens */ @@ -233,8 +242,10 @@ .banner.good .tick{color:var(--ok);font-weight:700} /* the toast — VSShell.toast(); one element, appended on first use */ -.vs-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; - background:var(--ink);color:var(--surface);padding:8px 14px;border-radius:8px; +/* It is a popover, so the browser's own [popover] rules apply first: inset, + margin and border are theirs to set and ours to put back. */ +.vs-toast{position:fixed;inset:auto;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; + background:var(--ink);color:var(--surface);border:0;margin:0;padding:8px 14px;border-radius:8px; font-size:12.5px;box-shadow:var(--shadow-pop);opacity:0;pointer-events:none;transition:opacity .25s} .vs-toast.on{opacity:1} @@ -443,11 +454,16 @@ + + +
@@ -685,14 +701,34 @@ } /* ── a toast: the page saying "done" without stopping anyone ── */ + /* Long enough for the opacity transition in shell.css to finish before the + toast leaves the top layer, so it fades rather than vanishing. */ + const TOAST_FADE_MS = 300; let toastTimer = null; function toast (msg, ms = 2200) { let el = document.querySelector('.vs-toast'); - if (!el) { el = document.createElement('div'); el.className = 'vs-toast'; document.body.appendChild(el) } + if (!el) { + el = document.createElement('div'); + el.className = 'vs-toast'; + /* A modal dialog paints in the top layer, above every z-index there is, + and its backdrop blurs what lies under it. A toast raised while one is + open has to join the top layer or it is unreadable behind the very + dialog whose failure it is reporting. */ + el.popover = 'manual'; + document.body.appendChild(el); + } el.textContent = msg; + /* Promoted on each toast rather than left open, because the top layer + 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 {} el.classList.add('on'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('on'), ms); + toastTimer = setTimeout(() => { + el.classList.remove('on'); + toastTimer = setTimeout(() => { try { el.hidePopover() } catch {} }, TOAST_FADE_MS); + }, ms); } /* ── two-step confirm on one button ── @@ -800,6 +836,9 @@ const btn = $('#settingsBtn'), menu = $('#settingsMenu'); if (!btn || !menu) return; const open = on => { menu.hidden = !on; btn.setAttribute('aria-expanded', String(on)) }; + // A control in the cog's slot can act on the page behind it, so the page + // needs a way to put the menu away first. + closeSettings = () => open(false); btn.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden) }); menu.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => open(false)); @@ -833,9 +872,12 @@ return api; } + let closeSettings = () => {}; + const api = { init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, + closeSettings: () => closeSettings(), get theme () { return theme }, get lang () { return lang }, onLang (fn) { langListeners.push(fn) }, diff --git a/plugins/vstack/experimental/spec/assets/spec-tree.html b/plugins/vstack/experimental/spec/assets/spec-tree.html index d2d0092..5947e67 100644 --- a/plugins/vstack/experimental/spec/assets/spec-tree.html +++ b/plugins/vstack/experimental/spec/assets/spec-tree.html @@ -7,58 +7,67 @@ /* One palette for every vstack page. Roles, not colours: a page asks for --surface, not for white, so light and dark are the same stylesheet. + The values come from `design/tokens.css`, which owns the palette. They are + copied rather than imported because a page has to work opened off disk and + inlined into an Artifact under a CSP that blocks every external request — + nothing here may be fetched. `tests/design-tokens.mjs` fails when the two + files disagree, so the copy cannot drift quietly. + Page-specific hues (the story map's phase bands, the board's new/have/touch, the spec's priorities) stay in the page, below this block — they mean something only there. Everything here is shared, and is the reason a board and a spec look like the same product. Three states, in this order: the OS preference, then an explicit choice. - `data-theme` absent means auto. */ + `data-theme` absent means auto. + + The type scale is the guide's; the families are not. Space Grotesk and Inter + would each be an external request, so every page reads in the system stack. */ :root{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); - --radius:9px; + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); + --radius:8px; --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color-scheme:light; } @media (prefers-color-scheme:dark){:root{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; }} :root[data-theme=light]{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); color-scheme:light; } :root[data-theme=dark]{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; } /* /vstack:shell tokens */ @@ -237,8 +246,10 @@ .banner.good .tick{color:var(--ok);font-weight:700} /* the toast — VSShell.toast(); one element, appended on first use */ -.vs-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; - background:var(--ink);color:var(--surface);padding:8px 14px;border-radius:8px; +/* It is a popover, so the browser's own [popover] rules apply first: inset, + margin and border are theirs to set and ours to put back. */ +.vs-toast{position:fixed;inset:auto;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; + background:var(--ink);color:var(--surface);border:0;margin:0;padding:8px 14px;border-radius:8px; font-size:12.5px;box-shadow:var(--shadow-pop);opacity:0;pointer-events:none;transition:opacity .25s} .vs-toast.on{opacity:1} @@ -560,11 +571,16 @@ + + +
@@ -914,14 +930,34 @@

} /* ── a toast: the page saying "done" without stopping anyone ── */ + /* Long enough for the opacity transition in shell.css to finish before the + toast leaves the top layer, so it fades rather than vanishing. */ + const TOAST_FADE_MS = 300; let toastTimer = null; function toast (msg, ms = 2200) { let el = document.querySelector('.vs-toast'); - if (!el) { el = document.createElement('div'); el.className = 'vs-toast'; document.body.appendChild(el) } + if (!el) { + el = document.createElement('div'); + el.className = 'vs-toast'; + /* A modal dialog paints in the top layer, above every z-index there is, + and its backdrop blurs what lies under it. A toast raised while one is + open has to join the top layer or it is unreadable behind the very + dialog whose failure it is reporting. */ + el.popover = 'manual'; + document.body.appendChild(el); + } el.textContent = msg; + /* Promoted on each toast rather than left open, because the top layer + 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 {} el.classList.add('on'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('on'), ms); + toastTimer = setTimeout(() => { + el.classList.remove('on'); + toastTimer = setTimeout(() => { try { el.hidePopover() } catch {} }, TOAST_FADE_MS); + }, ms); } /* ── two-step confirm on one button ── @@ -1029,6 +1065,9 @@

const btn = $('#settingsBtn'), menu = $('#settingsMenu'); if (!btn || !menu) return; const open = on => { menu.hidden = !on; btn.setAttribute('aria-expanded', String(on)) }; + // A control in the cog's slot can act on the page behind it, so the page + // needs a way to put the menu away first. + closeSettings = () => open(false); btn.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden) }); menu.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => open(false)); @@ -1062,9 +1101,12 @@

return api; } + let closeSettings = () => {}; + const api = { init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, + closeSettings: () => closeSettings(), get theme () { return theme }, get lang () { return lang }, onLang (fn) { langListeners.push(fn) }, diff --git a/plugins/vstack/experimental/start/assets/chooser-server.mjs b/plugins/vstack/experimental/start/assets/chooser-server.mjs index 0caf6e4..1d0b39d 100644 --- a/plugins/vstack/experimental/start/assets/chooser-server.mjs +++ b/plugins/vstack/experimental/start/assets/chooser-server.mjs @@ -74,10 +74,12 @@ if (PREFILL_PATH) { from here is not an error — it renders from its README with no tags. The same tables double as the catalog snapshot when the form runs before any clone. */ const KNOWN = { - 'vercel': { title:'Vercel SPA', tags:['React','SPA','Vercel','Neon'] }, - 'vercel-ssr': { title:'Vercel SSR', tags:['Next.js','SSR','Vercel','Neon'] }, - 'nextjs-nestjs-postgres': { title:'Next + NestJS', tags:['Next.js','NestJS','Postgres','Prisma'] }, - 'taro-fastify-mysql-tencent': { title:'Taro / Tencent', tags:['Taro','WeChat','Fastify','MySQL','Tencent'] }, + 'vercel-csr': { title:'Vercel SPA', tags:['React','Vite','Fastify','Postgres','Vercel'] }, + 'vercel-ssr': { title:'Vercel SSR', tags:['Next.js','SSR','Postgres','Vercel'] }, + 'enterprise': { title:'Next + NestJS', tags:['Next.js','NestJS','Postgres','Prisma'] }, + 'mern': { title:'MERN', tags:['React','Express','MongoDB','Mongoose'] }, + 'django': { title:'React + Django', tags:['React','Django','Postgres','Python'] }, + 'wechat': { title:'Taro / Tencent', tags:['Taro','H5','Fastify','MySQL','Tencent'] }, 'multi-tenancy': { title:'Multi-tenancy', tags:['tenant scoping','row isolation','scoped storage'] }, 'saas-billing': { title:'SaaS billing', tags:['plans','entitlements','seats','usage','webhooks'] }, 'otp-auth': { title:'OTP auth', tags:['OTP','SMS','email','challenge store'] }, @@ -89,10 +91,12 @@ const KNOWN = { } const DESC = { - 'vercel': 'Client-rendered React. No server rendering.', - 'vercel-ssr': 'Server-rendered Next.js. Marketing and app in one deployment.', - 'nextjs-nestjs-postgres': 'Separate API service with its own lifecycle.', - 'taro-fastify-mysql-tencent': 'WeChat mini-program, hosted in mainland China.', + 'vercel-csr': 'Client-rendered React with a Fastify API on Vercel.', + 'vercel-ssr': 'One full-stack Next.js application on Vercel.', + 'enterprise': 'Server-first Next.js with a separate NestJS API.', + 'mern': 'Client-rendered React with an Express API and MongoDB.', + 'django': 'Client-rendered React with a Django REST API.', + 'wechat': 'Mobile-first Taro H5, hosted on Tencent Cloud.', 'multi-tenancy': 'Organisations share one deployment, data stays isolated.', 'saas-billing': 'Subscriptions, entitlements and seats as a layer.', 'otp-auth': 'Sign in with a code sent by SMS or email.', @@ -104,7 +108,7 @@ const DESC = { } const SNAPSHOT = { - packs: ['vercel', 'vercel-ssr', 'nextjs-nestjs-postgres', 'taro-fastify-mysql-tencent'], + packs: ['vercel-csr', 'vercel-ssr', 'enterprise', 'mern', 'django', 'wechat'], addons: ['multi-tenancy', 'saas-billing', 'otp-auth', 'llm-calls', 'enterprise-compliance', 'test-mode', 'seo', 'premium-design'], } diff --git a/plugins/vstack/experimental/start/assets/chooser.html b/plugins/vstack/experimental/start/assets/chooser.html index c64c6da..94e337e 100644 --- a/plugins/vstack/experimental/start/assets/chooser.html +++ b/plugins/vstack/experimental/start/assets/chooser.html @@ -4,58 +4,67 @@ /* One palette for every vstack page. Roles, not colours: a page asks for --surface, not for white, so light and dark are the same stylesheet. + The values come from `design/tokens.css`, which owns the palette. They are + copied rather than imported because a page has to work opened off disk and + inlined into an Artifact under a CSP that blocks every external request — + nothing here may be fetched. `tests/design-tokens.mjs` fails when the two + files disagree, so the copy cannot drift quietly. + Page-specific hues (the story map's phase bands, the board's new/have/touch, the spec's priorities) stay in the page, below this block — they mean something only there. Everything here is shared, and is the reason a board and a spec look like the same product. Three states, in this order: the OS preference, then an explicit choice. - `data-theme` absent means auto. */ + `data-theme` absent means auto. + + The type scale is the guide's; the families are not. Space Grotesk and Inter + would each be an external request, so every page reads in the system stack. */ :root{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); - --radius:9px; + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); + --radius:8px; --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color-scheme:light; } @media (prefers-color-scheme:dark){:root{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; }} :root[data-theme=light]{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); color-scheme:light; } :root[data-theme=dark]{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; } /* /vstack:shell tokens */ @@ -226,8 +235,10 @@ .banner.good .tick{color:var(--ok);font-weight:700} /* the toast — VSShell.toast(); one element, appended on first use */ -.vs-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; - background:var(--ink);color:var(--surface);padding:8px 14px;border-radius:8px; +/* It is a popover, so the browser's own [popover] rules apply first: inset, + margin and border are theirs to set and ours to put back. */ +.vs-toast{position:fixed;inset:auto;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; + background:var(--ink);color:var(--surface);border:0;margin:0;padding:8px 14px;border-radius:8px; font-size:12.5px;box-shadow:var(--shadow-pop);opacity:0;pointer-events:none;transition:opacity .25s} .vs-toast.on{opacity:1} @@ -485,11 +496,16 @@ + + +
@@ -588,14 +604,18 @@

"base": ["CLAUDE.md","apps/","db/","design/","specs/","infra/",".github/"], "prefill": null, "packs": [ - { "id":"vercel", "title":"Vercel SPA", "desc":"Client-rendered React. No server rendering.", - "tags":["React","SPA","Vercel","Neon"] }, - { "id":"vercel-ssr", "title":"Vercel SSR", "desc":"Server-rendered Next.js. Marketing and app in one deployment.", - "tags":["Next.js","SSR","Vercel","Neon"] }, - { "id":"nextjs-nestjs-postgres", "title":"Next + NestJS", "desc":"Separate API service with its own lifecycle.", + { "id":"vercel-csr", "title":"Vercel SPA", "desc":"Client-rendered React with a Fastify API on Vercel.", + "tags":["React","Vite","Fastify","Postgres","Vercel"] }, + { "id":"vercel-ssr", "title":"Vercel SSR", "desc":"One full-stack Next.js application on Vercel.", + "tags":["Next.js","SSR","Postgres","Vercel"] }, + { "id":"enterprise", "title":"Next + NestJS", "desc":"Server-first Next.js with a separate NestJS API.", "tags":["Next.js","NestJS","Postgres","Prisma"] }, - { "id":"taro-fastify-mysql-tencent", "title":"Taro / Tencent", "desc":"WeChat mini-program, hosted in mainland China.", - "tags":["Taro","WeChat","Fastify","MySQL","Tencent"] } + { "id":"mern", "title":"MERN", "desc":"Client-rendered React with an Express API and MongoDB.", + "tags":["React","Express","MongoDB","Mongoose"] }, + { "id":"django", "title":"React + Django", "desc":"Client-rendered React with a Django REST API.", + "tags":["React","Django","Postgres","Python"] }, + { "id":"wechat", "title":"Taro / Tencent", "desc":"Mobile-first Taro H5, hosted on Tencent Cloud.", + "tags":["Taro","H5","Fastify","MySQL","Tencent"] } ], "addons": [ { "id":"multi-tenancy", "title":"Multi-tenancy", "desc":"Organisations share one deployment, data stays isolated.", @@ -782,14 +802,34 @@

} /* ── a toast: the page saying "done" without stopping anyone ── */ + /* Long enough for the opacity transition in shell.css to finish before the + toast leaves the top layer, so it fades rather than vanishing. */ + const TOAST_FADE_MS = 300; let toastTimer = null; function toast (msg, ms = 2200) { let el = document.querySelector('.vs-toast'); - if (!el) { el = document.createElement('div'); el.className = 'vs-toast'; document.body.appendChild(el) } + if (!el) { + el = document.createElement('div'); + el.className = 'vs-toast'; + /* A modal dialog paints in the top layer, above every z-index there is, + and its backdrop blurs what lies under it. A toast raised while one is + open has to join the top layer or it is unreadable behind the very + dialog whose failure it is reporting. */ + el.popover = 'manual'; + document.body.appendChild(el); + } el.textContent = msg; + /* Promoted on each toast rather than left open, because the top layer + 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 {} el.classList.add('on'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('on'), ms); + toastTimer = setTimeout(() => { + el.classList.remove('on'); + toastTimer = setTimeout(() => { try { el.hidePopover() } catch {} }, TOAST_FADE_MS); + }, ms); } /* ── two-step confirm on one button ── @@ -897,6 +937,9 @@

const btn = $('#settingsBtn'), menu = $('#settingsMenu'); if (!btn || !menu) return; const open = on => { menu.hidden = !on; btn.setAttribute('aria-expanded', String(on)) }; + // A control in the cog's slot can act on the page behind it, so the page + // needs a way to put the menu away first. + closeSettings = () => open(false); btn.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden) }); menu.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => open(false)); @@ -930,9 +973,12 @@

return api; } + let closeSettings = () => {}; + const api = { init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, + closeSettings: () => closeSettings(), get theme () { return theme }, get lang () { return lang }, onLang (fn) { langListeners.push(fn) }, @@ -1149,11 +1195,11 @@

/* What the answers recommend on step 3 — pack to pre-select. A missing key, or a pack this inventory doesn't carry, recommends nothing. */ const RECOMMEND = { - 'website:*': { pack:'vercel-ssr', addons:['seo'] }, - 'webapp:saas': { pack:'nextjs-nestjs-postgres', addons:['multi-tenancy','saas-billing'] }, - 'webapp:internal': { pack:'vercel', addons:['test-mode'] }, - 'mobile:wechat': { pack:'taro-fastify-mysql-tencent', addons:['otp-auth'] }, - 'api:*': { pack:'nextjs-nestjs-postgres', addons:[] }, + 'website:*': { pack:'vercel-ssr', addons:['seo'] }, + 'webapp:saas': { pack:'enterprise', addons:['multi-tenancy','saas-billing'] }, + 'webapp:internal': { pack:'vercel-csr', addons:['test-mode'] }, + 'mobile:wechat': { pack:'wechat', addons:['otp-auth'] }, + 'api:*': { pack:'enterprise', addons:[] }, }; const state = { diff --git a/plugins/vstack/hooks/hooks.json b/plugins/vstack/hooks/hooks.json new file mode 100644 index 0000000..fe6c5b5 --- /dev/null +++ b/plugins/vstack/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "description": "Hold a turn open while a review round is still unanswered.", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/round-gate.mjs\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/plugins/vstack/hooks/round-gate.mjs b/plugins/vstack/hooks/round-gate.mjs new file mode 100644 index 0000000..4f09985 --- /dev/null +++ b/plugins/vstack/hooks/round-gate.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/* + * round-gate.mjs — the Stop hook that keeps a review round from ending halfway. + * + * Claude Code is the one Host that can gate the end of a turn, so this sits + * outside the engine with the rest of the host-specific wiring. It decides + * nothing itself: `review-server.mjs unanswered` owns what an unfinished round is, + * and this turns its answer into the block Claude Code understands. + */ + +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const SERVER = path.join(HERE, '..', 'skills', 'review', 'assets', 'review-server.mjs') + +const chunks = [] +for await (const chunk of process.stdin) chunks.push(chunk) +let input = {} +try { input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch {} + +/* One block per turn. The gate names what is missing once; a session that means + to stop with a round open — because the reviewer asked it to — must still be + able to. Nothing is lost either way: an unanswered comment stays open and + comes back on the next delivery. */ +if (input.stop_hook_active) process.exit(0) + +/* The payload names the session this Stop belongs to, and `unanswered` answers + for that session alone — a second session in the same directory must not be + told it owes a round its watcher never took delivery of. */ +const session = input.session_id ? ['--session', String(input.session_id)] : [] +const check = spawnSync(process.execPath, [SERVER, 'unanswered', '--all', ...session], { + cwd: input.cwd || process.cwd(), encoding: 'utf8', timeout: 5000, +}) + +/* Exit 1 with something to say is the only answer that blocks. A check that + could not run knows nothing about the round, and a hook that turned a broken + check into a stuck turn would be the worse failure by far. */ +if (check.status !== 1 || !check.stdout?.trim()) process.exit(0) + +console.log(JSON.stringify({ + decision: 'block', + reason: `A review round is open and you have not handed it back.\n\n${check.stdout.trim()}`, +})) diff --git a/plugins/vstack/host-profiles/codex.json b/plugins/vstack/host-profiles/codex.json index c51e3d6..d651eec 100644 --- a/plugins/vstack/host-profiles/codex.json +++ b/plugins/vstack/host-profiles/codex.json @@ -5,6 +5,14 @@ "share": "copy", "watch": "stream", "browser": true, - "updateDetect": "none" + "updateDetect": "codex-install" + }, + "install": { + "howLead": "Run these in your shell, then start a new Codex thread:", + "commands": [ + "codex plugin marketplace upgrade cavalry-collective", + "codex plugin add vstack@cavalry-collective" + ], + "auto": null } } diff --git a/plugins/vstack/lib/shell/shell.css b/plugins/vstack/lib/shell/shell.css index 9aa50f4..44107e0 100644 --- a/plugins/vstack/lib/shell/shell.css +++ b/plugins/vstack/lib/shell/shell.css @@ -153,8 +153,10 @@ .banner.good .tick{color:var(--ok);font-weight:700} /* the toast — VSShell.toast(); one element, appended on first use */ -.vs-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; - background:var(--ink);color:var(--surface);padding:8px 14px;border-radius:8px; +/* It is a popover, so the browser's own [popover] rules apply first: inset, + margin and border are theirs to set and ours to put back. */ +.vs-toast{position:fixed;inset:auto;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; + background:var(--ink);color:var(--surface);border:0;margin:0;padding:8px 14px;border-radius:8px; font-size:12.5px;box-shadow:var(--shadow-pop);opacity:0;pointer-events:none;transition:opacity .25s} .vs-toast.on{opacity:1} diff --git a/plugins/vstack/lib/shell/shell.js b/plugins/vstack/lib/shell/shell.js index 1d46b94..cc1ac10 100644 --- a/plugins/vstack/lib/shell/shell.js +++ b/plugins/vstack/lib/shell/shell.js @@ -160,14 +160,34 @@ window.VSShell = (function () { } /* ── a toast: the page saying "done" without stopping anyone ── */ + /* Long enough for the opacity transition in shell.css to finish before the + toast leaves the top layer, so it fades rather than vanishing. */ + const TOAST_FADE_MS = 300; let toastTimer = null; function toast (msg, ms = 2200) { let el = document.querySelector('.vs-toast'); - if (!el) { el = document.createElement('div'); el.className = 'vs-toast'; document.body.appendChild(el) } + if (!el) { + el = document.createElement('div'); + el.className = 'vs-toast'; + /* A modal dialog paints in the top layer, above every z-index there is, + and its backdrop blurs what lies under it. A toast raised while one is + open has to join the top layer or it is unreadable behind the very + dialog whose failure it is reporting. */ + el.popover = 'manual'; + document.body.appendChild(el); + } el.textContent = msg; + /* Promoted on each toast rather than left open, because the top layer + 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 {} el.classList.add('on'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('on'), ms); + toastTimer = setTimeout(() => { + el.classList.remove('on'); + toastTimer = setTimeout(() => { try { el.hidePopover() } catch {} }, TOAST_FADE_MS); + }, ms); } /* ── two-step confirm on one button ── @@ -275,6 +295,9 @@ window.VSShell = (function () { const btn = $('#settingsBtn'), menu = $('#settingsMenu'); if (!btn || !menu) return; const open = on => { menu.hidden = !on; btn.setAttribute('aria-expanded', String(on)) }; + // A control in the cog's slot can act on the page behind it, so the page + // needs a way to put the menu away first. + closeSettings = () => open(false); btn.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden) }); menu.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => open(false)); @@ -308,9 +331,12 @@ window.VSShell = (function () { return api; } + let closeSettings = () => {}; + const api = { init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, + closeSettings: () => closeSettings(), get theme () { return theme }, get lang () { return lang }, onLang (fn) { langListeners.push(fn) }, diff --git a/plugins/vstack/lib/shell/tokens.css b/plugins/vstack/lib/shell/tokens.css index 87d2b33..cfea2a5 100644 --- a/plugins/vstack/lib/shell/tokens.css +++ b/plugins/vstack/lib/shell/tokens.css @@ -1,57 +1,66 @@ /* One palette for every vstack page. Roles, not colours: a page asks for --surface, not for white, so light and dark are the same stylesheet. + The values come from `design/tokens.css`, which owns the palette. They are + copied rather than imported because a page has to work opened off disk and + inlined into an Artifact under a CSP that blocks every external request — + nothing here may be fetched. `tests/design-tokens.mjs` fails when the two + files disagree, so the copy cannot drift quietly. + Page-specific hues (the story map's phase bands, the board's new/have/touch, the spec's priorities) stay in the page, below this block — they mean something only there. Everything here is shared, and is the reason a board and a spec look like the same product. Three states, in this order: the OS preference, then an explicit choice. - `data-theme` absent means auto. */ + `data-theme` absent means auto. + + The type scale is the guide's; the families are not. Space Grotesk and Inter + would each be an external request, so every page reads in the system stack. */ :root{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); - --radius:9px; + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); + --radius:8px; --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color-scheme:light; } @media (prefers-color-scheme:dark){:root{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; }} :root[data-theme=light]{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); color-scheme:light; } :root[data-theme=dark]{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; } diff --git a/plugins/vstack/lib/shell/topbar.html b/plugins/vstack/lib/shell/topbar.html index ce1bde4..70bf6d0 100644 --- a/plugins/vstack/lib/shell/topbar.html +++ b/plugins/vstack/lib/shell/topbar.html @@ -63,11 +63,16 @@ + + +
diff --git a/plugins/vstack/lib/update-check.mjs b/plugins/vstack/lib/update-check.mjs index 4272bbe..0093297 100644 --- a/plugins/vstack/lib/update-check.mjs +++ b/plugins/vstack/lib/update-check.mjs @@ -9,11 +9,20 @@ * dismissable line under the bar. * * WHAT COUNTS AS NEWER - * This asks the question Claude Code itself would ask, so the banner never - * disagrees with what `/plugin update` would do. Claude Code keys its update - * decision on the plugin's `version` when plugin.json declares one, and on the - * git commit the plugin was installed from when it does not, recording either - * in ~/.claude/plugins/installed_plugins.json. + * This asks the question the Host itself would ask, so the banner never + * disagrees with what that Host's own update command would do. Each Host says + * which question that is in `capabilities.updateDetect`, and each records an + * install somewhere different: + * + * claude-install Claude Code keys its update decision on the plugin's + * `version` when plugin.json declares one, and on the git + * commit the plugin was installed from when it does not, + * recording either in ~/.claude/plugins/installed_plugins.json. + * codex-install Codex keeps no such record. It unpacks each release into + * ~/.codex/plugins/cache////, + * so the directory the running copy sits in is the version + * Codex resolved, and its presence is what proves an install. + * none The Host has no install to compare. No banner. * * plugin.json declares a version, so the comparison is normally version against * version. A copy installed before that version existed has no version on @@ -21,7 +30,7 @@ * branch. * * A working copy is not an install. Running from a clone (developing the plugin - * itself) finds no entry, and the check returns nothing rather than telling you + * itself) matches nothing, and the check returns nothing rather than telling you * your own uncommitted branch is out of date. * * What it does, exactly, so nothing here is a surprise: @@ -40,13 +49,20 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { injectHead } from './live-link.mjs' -const HERE = path.dirname(fileURLToPath(import.meta.url)) +/* Deciding whether one directory contains another is a string compare, and a + symlinked prefix — /var and /tmp on macOS, a home directory someone moved — + gives the same directory two names. Node hands a module its real path, so + every path compared against that one is put through the filesystem too. */ +const realPath = p => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } } + +const HERE = realPath(path.dirname(fileURLToPath(import.meta.url))) const MANIFEST = path.join(HERE, '..', '.claude-plugin', 'plugin.json') const REPO = 'Cavalry-Collective/visual-stack' const BRANCH = 'main' const MARKET = 'cavalry-collective' // .claude-plugin/marketplace.json → name 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') const TTL_MS = 6 * 60 * 60 * 1000 const TIMEOUT_MS = 2500 @@ -66,7 +82,7 @@ function installedCopy () { if (!all) return null for (const [id, entries] of Object.entries(all)) { for (const e of entries || []) { - const root = e.installPath && path.resolve(e.installPath) + const root = e.installPath && realPath(e.installPath) if (root && (HERE === root || HERE.startsWith(root + path.sep))) { return { id, sha: e.gitCommitSha || null, version: e.version || null } } @@ -75,6 +91,19 @@ function installedCopy () { return null } +/** + * The Codex install this file belongs to, read from where it is sitting. Codex + * writes no install record, so the path is the record: a copy under + * //// was put there by `codex plugin add` + * at that version, and a copy anywhere else is a clone. + */ +function codexCopy () { + const root = realPath(CODEX_CACHE) + if (!HERE.startsWith(root + path.sep)) return null + const version = path.relative(root, HERE).split(path.sep)[0] + return version ? { id: `${PLUGIN}@${MARKET}`, sha: null, version } : null +} + /** 4.10.0 is newer than 4.9.3 — compare numbers, not strings. */ function isNewer (a, b) { const parts = v => String(v).split('-')[0].split('.').map(n => parseInt(n, 10) || 0) @@ -167,15 +196,18 @@ export function dismissUpdate (key) { * `{ pill, key, title, install, howLead, auto }` when there is something newer, * otherwise null. Never throws, never blocks longer than the timeout. * - * @param {object} [hostProfile] Host profile (contracts/host.md). When - * capabilities.updateDetect is "none", returns null. install/howLead/auto - * come from the profile when present so banners stay host-agnostic. + * @param {object} [hostProfile] Host profile (contracts/host.md). + * capabilities.updateDetect picks where an installed copy is looked for, and + * "none" returns null without looking. install/howLead/auto come from the + * profile when present so banners stay host-agnostic. */ export async function checkForUpdate (hostProfile = null) { if (process.env.VSTACK_NO_UPDATE_CHECK) return null - if (hostProfile?.capabilities?.updateDetect === 'none') return null + const detect = hostProfile?.capabilities?.updateDetect || 'claude-install' + const find = { 'claude-install': installedCopy, 'codex-install': codexCopy }[detect] + if (!find) return null // "none", or a Host this copy predates - const installed = installedCopy() + const installed = find() if (!installed) return null // a clone is not an install let words = null diff --git a/plugins/vstack/skills/review/SKILL.md b/plugins/vstack/skills/review/SKILL.md index 0a4bebd..7509531 100644 --- a/plugins/vstack/skills/review/SKILL.md +++ b/plugins/vstack/skills/review/SKILL.md @@ -25,13 +25,13 @@ tool mapping. A two-way review loop. The user comments on the screen; you apply the comments, ask about anything ambiguous, and publish the next version. Two things can go under it: -| | what it is | what a round changes | +| | what it is | what you change | |---|---|---| | **A page** (§1–§3) | a wireframe you just generated, an exported screen, a prototype — any self-contained HTML file | the file | | **A UI that exists** (§7) | an app on localhost, or a website on the internet: the real screens, real data, real states | the source code — or, for a site you don't own, a note about it | ``` -requirements ──► page.html ──► review workspace ──► feedback.md ──┐ +requirements ──► page.html ──► review workspace ──► brief.md ─────┐ ▲ or a live app (user comments) │ └──────── you apply it, reply, publish v(N+1) ◄──────────────┘ ``` @@ -73,7 +73,7 @@ With a **URL**, use Host op **`browser_capture`** when the adapter says it is available; otherwise ask the user for screenshots and derive by eye (§2 screenshots path). The full procedure — screenshots per size, `assets/harvest-reference.js`, writing `/-reference.md` so every -later round reads it instead of re-capturing, the never-sign-in rule — is +later pass reads it instead of re-capturing, the never-sign-in rule — is `references/design-sources.md` §1–2. Copying a *layout* is the point; logos, wordmarks, photography and copy stay placeholders. @@ -134,15 +134,16 @@ The page opens in **its own browser window** on the canvas — own viewport, own | **Attached to an element** | a comment belongs to the thing it was made on, not to a coordinate. The mark rides it when the layout moves, and **goes off the page with it** — a comment made inside a modal, tab or step is not drawn while that thing is closed. It stays in the list tagged *not on screen*, and it still reaches you | | Captions | stay hidden — a mark shows its note when it's open, or on hover in Annotate | | **Screen size** | ultrawide · desktop · tablet · phone. A comment belongs to the size it was made at and only shows there | -| **Thread** | your replies appear on the comment itself and in the comment list, where they can be answered without going back to the mark. A question opens its thread on sight | +| **Thread** | your replies appear on the comment itself and in the comment list, where they can be answered without going back to the mark. A question opens its thread on sight. A question that came with options shows them as buttons, one marked *Recommended*; pressing one answers with those words, and the box below still takes anything else | | **Save** (⏎) | on the comment — Enter commits it, Shift+Enter is a new line | | **Timeline** (bottom) | drag the handle to scrub through published versions; history is read-only | | **EN / 中文** | workspace chrome only — comments stay in whatever words they were written in | -| **Delete** | on the comment, once it has words in it | -| **Clear all** | in the comment list footer, behind a confirm | +| **Delete** | on the comment, once it has words in it. It takes the comment off the user's list whatever state it is in, including one you are working on right now — you are not told, and you finish and close what you were given as normal | +| **Clear all** | in the comment list footer, behind a confirm. It takes the addressed comments off the list — the same act as the per-card delete. Comments still open stay unless the reviewer ticks the box on the confirm, which is off every time it is asked | | **Link status** | a dot beside Send — linked to your session, or link lost. Nothing is said until the connection has actually answered | | **Send to {agent}** (⌘⏎) | sends straight through — no preview step — and wakes you up. Label uses the Host profile name. Greys out until something actually changes | | **In flight** | every comment you were sent keeps an indeterminate progress bar until you publish or reply. No banner covers the page any more — the progress is on the comments it belongs to | +| **Stalled** | after a minute with nothing listening, the strip stops claiming progress and says you have stalled. **Send again** puts those comments back in the queue, and the next session to pick up is handed them. Refused while your watcher is alive, because then you still have them | | **Addressed** | comments you closed stay in the list in their own section, each offering **Revert** or **Refine** | | **Publish a link to this wireframe** (the ▾ beside Send) | only when Host `capabilities.share` is `artifact`. Asks you to publish **the wireframe** (Host op `share`) and hand the URL back. Hidden on hosts without public share, and in a live review | | **Approve & finish** (the ▾ beside Send) | sign-off. Ends the review, closes the server, and tells you the design is settled — behind a confirm that warns how many comments are being left unapplied | @@ -173,9 +174,14 @@ is a different op from the `background` you used in §3. node "$SKILL/assets/review-server.mjs" watch --all --stream # or --file ``` +Use the exact command your adapter's `watch_stream` entry gives, not the bare form above: it adds +`--session ` when your host has one, and that is what binds each delivery to you — +without it, the round you take cannot be told apart from another session's. + Each line of its output is one event, delivered to you as it happens, and the process keeps running, so one watcher covers the whole session — `--all` takes in every review open in the project, including ones opened later, and any you started from this directory whose page lives elsewhere. +It never takes over a review another session's watcher is already covering. Run it from the same directory you started the server from; that is what ties the two together. **It opens with a `HANDSHAKE` line naming a command. Run that command straight away.** The watcher @@ -184,7 +190,7 @@ Answering proves the op was fulfilled, since only a session that can run command Answer within two minutes; after that the watcher prints `UNWIRED` and exits, and you start it again with the tool your adapter names for `watch_stream`. -The page says **Linked** for as long as the watcher is answered and the rounds are being claimed, +The page says **Linked** for as long as the watcher is answered, and **Unlinked** in amber the rest of the time, so the reviewer always knows which one they have. Each event is one line (full table: `contracts/review-loop.md`): @@ -195,56 +201,77 @@ Each event is one line (full table: `contracts/review-loop.md`): | **`LINKED`** | the handshake is answered and a review is under the watcher; the workspace says Linked | carry on — the loop is live | | **`UNLINKED`** | the handshake is answered, but the watcher found no review to cover, so no workspace says Linked | start it again with `--file ` if a review is already running for a page outside this directory. A serve started here after it needs nothing | | **`UNWIRED`** | the handshake went unanswered and the watcher exited | start it again with the tool your adapter names for `watch_stream` | -| **`REVIEW`** | a review landed; the line names its round and brief | `claim` the round, then apply it — the steps below | -| **`REPLIED`** | they answered a question you asked | read the thread and carry on with that comment. Nothing else announces this — a reply writes no sentinel | +| **`REVIEW`** | comments have been handed to you; the line names how many are open and where the brief is | read the brief, then the steps below | | **`SHARE`** | they want a link to send someone | Host op `share` if capable, then §6; if the Host cannot share publicly, say so and offer a file/bundle instead | | **`APPROVED`** | the design is signed off; the server has closed itself | say it's approved, note any `openComments` deliberately left, and carry on with whatever comes next | | **`CLOSED`** | that review's tab went away | the watcher drops it and keeps watching the rest; it only stops when none are left | -**Use the protocol commands rather than deleting state files.** `claim --round …` consumes `pending` -and `share --url` clears `share`. The durable round record remains available for validation, -recovery, and idempotent retries. - -### Checking during a round - -While you work, **no waiter is armed** — nothing will interrupt you, and a round in flight cannot be -called off. If the reviewer changes their mind they send again, and that brief supersedes. +**Use the protocol commands rather than deleting state files.** `share --url` clears `share`, and +closing a comment is `publish --close`. -**Check at the checkpoints of a long round** — after reading the feedback, and before `publish`: - -```bash -node "$SKILL/assets/review-server.mjs" check --file "$FILE" -``` +### What a delivery is -It always exits 0. It exists to name a round sitting in the queue that nobody has claimed: if it does, -claim that round before anything else, because comments are sitting unread. +The watcher blocks until the reviewer has said something you have not been given, then hands you +**every open comment** — not only the new ones — marking which are new since last time. A comment you +do not close comes back on the next delivery, so nothing is lost by being missed. -The longer the round, the more it matters: a check costs nothing, and one that never runs makes the -button a lie. +While you work, **nothing will interrupt you.** New comments accumulate on the server and arrive with +the next delivery; a review in flight cannot be called off. -On a review landing: +On a delivery: -1. Claim the round named by the `REVIEW` event. This acknowledges delivery without discarding its ledger: +1. **Read the brief** the `REVIEW` line names (`/brief.md`). It carries every open comment with + its element, place, screen size and thread. +2. **Apply every comment.** There are no priorities to sort by — if the reviewer wrote it down, it + needs doing. Locate each from its **anchor** — the element and the region it sits in — at the screen + size it was made at, using the coordinates only to break a tie. +3. **Ask instead of guessing.** If a comment is ambiguous, reply to it — the question appears on the + mark and in the comment list, where the user answers it: ```bash - node "$SKILL/assets/review-server.mjs" claim --file "$FILE" --round r17 + node "$SKILL/assets/review-server.mjs" reply --file "$FILE" \ + --comment c7f2a1 --text "Every overdue row, or only the ones assigned to you?" ``` -2. Read `feedback.md` (the claim output names it). It carries a markdown brief plus a JSON block with every comment's element, place, screen size and thread. -3. **Apply every comment that isn't addressed.** There are no priorities to sort by — if the reviewer wrote it down, it needs doing. Locate each from its **anchor** — the element and the region it sits in — at the screen size it was made at, using the coordinates only to break a tie. -4. **Ask instead of guessing.** If a comment is ambiguous, reply to it — the question appears on the mark and in the comment list, where the user answers it: + The comment stays open and comes back with their answer attached. On disk the reply uses + `by: "agent"` (legacy files may say `"claude"`; treat them the same). + + **When the answers are a short list, offer them.** `--option`, repeated, puts them on the + comment as buttons, and `--recommend ` marks the one you would take. Pressing one answers + with those words; the box to type something else stays, so an answer you did not think of is + still one sentence away. ```bash - node "$SKILL/assets/review-server.mjs" reply --file "$FILE" \ - --round r17 --comment c7f2a1 --text "Every overdue row, or only the ones assigned to you?" + node "$SKILL/assets/review-server.mjs" reply --file "$FILE" --comment c7f2a1 \ + --text "Every overdue row, or only the ones assigned to you?" \ + --option "Every overdue row" --option "Only mine" --recommend 2 ``` - That comment goes to *{agent} asked* until they answer, which flips it back to open and returns it in the next round with their reply attached. On disk the reply uses `by: "agent"` (legacy files may say `"claude"`; treat them the same). -5. If a comment is genuinely wrong for the design, reply saying why rather than silently skipping it. -6. Publish, closing out what you actually did: + Two to four options, each a complete answer rather than a keyword. Ask an open question with + `--text` alone when the answer is a sentence you cannot predict. +4. If a comment is genuinely wrong for the design, reply saying why rather than silently skipping it. +5. **Close what you did, and snapshot the version:** ```bash node "$SKILL/assets/review-server.mjs" publish --file "$FILE" \ - --round r17 --label "Filters collapsed, overdue sorts first" --addressed c1f3k2,c9dk1 + --close c1f3k2,c9dk1 --label "Filters collapsed, overdue sorts first" \ + --summary "Filters are collapsed behind a single control, and overdue rows sort first. + I left the date column alone — say if you want it narrower too." ``` - Only `--addressed` marks a comment done. Publish fails before creating a version if the round was - not claimed, an id is unknown or stale, or any open comment is unaccounted for. -7. Leave **`watch_stream` running** and say what changed in a few lines. Then wait — don't ask "shall I continue?", the loop is the point. (Only re-arm if you used one-shot `watch` without `--stream`.) + Anything you do not name stays open and comes back. Publish tells you what it left open — close + those or reply asking about them, because the delivery will not raise them again on its own. + + **`--label` names the version in one line. `--summary` is the account you would give in + chat** — what you changed, what you decided, what you left. The workspace shows it on the + banner when the round lands, so a reviewer who is not reading your terminal still gets it. + Send the same words to both places rather than writing a thinner version for the page. One + summary is kept, and it is the latest one: a publish without `--summary` clears it. +6. **Check you left nothing hanging**, before you finish your turn: + ```bash + node "$SKILL/assets/review-server.mjs" unanswered --all + ``` + It exits 1 and names every comment you were handed and then said nothing about — neither closed + nor replied to. Those are the ones nothing will remind you of again, because the next delivery + only comes when the reviewer writes. Add `--session ` if your adapter names one, + so the answer covers your deliveries and not another session's. On Claude Code a Stop hook runs + this for you, with your session id, and holds your turn open until it is clean. +7. Leave **`watch_stream` running** and say what changed in a few lines. Then wait — don't ask "shall I + continue?", the loop is the point. (Only re-arm if you used one-shot `watch` without `--stream`.) **Closing the browser tab closes the review.** The workspace holds an SSE connection; when the last one goes and none returns within the grace period @@ -255,12 +282,9 @@ Either way, **say when the review is closed** — the user should never have to a socket is still open. The workspace never swaps the page out from under the reviewer: while you work, each comment you were -sent carries its own progress bar, and on publish the page offers **"vN is ready — Review changes"**. -Nothing interrupts them mid-round. Publish once, when the round is done. - -The reviewer keeps writing while you work — the workspace holds those comments and sends them as one -batch the moment your round ends. So a `REVIEW` event landing right after your publish is normal: -it is the queue they built up while you worked, not an echo of the round you just finished. +handed carries its own progress bar, and on publish the page offers a green line saying the round is +done, with **Refresh** beside it and your `--summary` under it, behind a chevron that opens and +closes and stays however the reviewer last left it. ## 6 · Publish the wireframe as a shareable link @@ -291,7 +315,7 @@ node "$SKILL/assets/review-server.mjs" share --file "$FILE" --url "" ``` - **Publish straight after a `publish`**, so the file on disk is the version you're - claiming to have shared. `$FILE` is the live working copy — mid-round it can be ahead of + claiming to have shared. `$FILE` is the live working copy — mid-review it can be ahead of the last published version. - The page is already self-contained (§2). - The link is tagged with the version it came from. After a later round the menu offers @@ -346,8 +370,8 @@ than the front door. A public site needs none of that — point at it and go. clicks through the app to reach a screen, or types a path in the address bar. So a review can span the whole flow in one pass — the brief comes back grouped by screen size, each comment naming its **Route**. -- **A version is a round, not a file.** `publish --name --label "…" - --addressed …` records that you finished a round; there is no snapshot of a +- **A version is a marker, not a file.** `publish --name --label "…" + --close …` records what you finished; there is no snapshot of a file because the app is the truth. The timeline still scrubs: the workspace captures the DOM of the screen they were commenting on each time they send, so history shows what they were looking at when they said it. @@ -359,7 +383,7 @@ than the front door. A public site needs none of that — point at it and go. If the app hot-reloads, the reviewer watches your change land. That is an argument for landing whole changes rather than halves, not for working slower — -and `check` (§5) still matters, because a round in a live app can be stopped +and the delivery loop (§5) still matters, because a review of a live app can be stopped mid-flight just as easily. ### A public website works too @@ -417,8 +441,7 @@ route. - **Every vstack tool writes under `.vstack/local//`**, so a project grows one dot-directory, not one per engine. `lib/workdir.mjs` resolves it — use that rather than joining the path by hand. One gitignore line covers the lot (`**/.vstack/local/`); the rest of `.vstack/` is the pipeline and belongs in the repo. - The server binds to `127.0.0.1` only. Port 7788 busy usually means a review server is already running — pass `--port`. - `node "$SKILL/assets/review-server.mjs" status --file "$FILE"` prints the current version, whether a review is waiting, and any sign-off / share request outstanding. -- `check --file "$FILE"` is the same question in one line, and always exits 0. Use it inside a round, where `status` is too much output to read repeatedly. If it names a round waiting unclaimed, claim that round before anything else — comments are sitting unread. -- **Every command takes `--name ` in place of `--file` for a live review** — `publish`, `reply`, `share`, `status`, `check`. The brief tells you which name to use. +- **Every command takes `--name ` in place of `--file` for a live review** — `publish`, `reply`, `share`, `status`, `watch`. The brief tells you which name to use. - Full command reference and troubleshooting: `references/workflow.md`. - Contracts: `plugins/vstack/contracts/` — Host ops and review-loop protocol. - Host adapters: `skills/review/hosts/claude.md`, `skills/review/hosts/codex.md`, `skills/review/hosts/grok.md`. diff --git a/plugins/vstack/skills/review/assets/bundle-artifact.mjs b/plugins/vstack/skills/review/assets/bundle-artifact.mjs index a41a832..3bd6ad5 100644 --- a/plugins/vstack/skills/review/assets/bundle-artifact.mjs +++ b/plugins/vstack/skills/review/assets/bundle-artifact.mjs @@ -5,16 +5,17 @@ * The local loop needs a server (same-origin page, POST-back feedback). To * put the same workspace in front of someone who is not at this machine, we * inline the page and its published versions into the workspace and switch the - * send button to "copy for Claude". Output is CSP-safe: no external fonts, - * scripts, styles or fetches — publishable as a Claude Artifact as-is. + * send button to copying the comments for the agent. Output is CSP-safe: no + * external fonts, scripts, styles or fetches — publishable as an Artifact as-is. * - * node bundle-artifact.mjs --file [--out review.html] [--versions 3] + * node bundle-artifact.mjs --file [--out review.html] [--versions 3] [--host ] */ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { subjectDir, TOOL } from '../../../lib/workdir.mjs' +import { loadHost, resolveHostId, withHost } from '../../../lib/host.mjs' const HERE = path.dirname(fileURLToPath(import.meta.url)) @@ -60,16 +61,26 @@ if (fs.existsSync(vdir)) { .sort((a, b) => a.n - b.n) } -/* Reviews so far, so the shared copy shows what is already answered. */ -const reviews = {} -const rdir = path.join(STORE, 'reviews') -if (fs.existsSync(rdir)) { - for (const d of fs.readdirSync(rdir)) { - const m = /^v(\d+)$/.exec(d) - if (!m) continue - const saved = readJSON(path.join(rdir, d, 'annotations.json')) - if (saved) reviews[m[1]] = saved +/* The comments so far, so the shared copy shows what is already answered. A + store filled by an older version keeps them one directory per version, newest + copy of each id winning. */ +let comments = readJSON(path.join(STORE, 'comments.json'))?.comments +if (!comments) { + const newest = new Map() + const rdir = path.join(STORE, 'reviews') + const versions = fs.existsSync(rdir) + ? fs.readdirSync(rdir).flatMap(d => { const m = /^v(\d+)$/.exec(d); return m ? [Number(m[1])] : [] }).sort((a, b) => a - b) + : [] + for (const v of versions) { + for (const old of readJSON(path.join(rdir, `v${v}`, 'annotations.json'))?.annotations || []) { + newest.set(old.id, { + ...old, + state: old.status === 'addressed' || old.dismissed ? 'closed' : 'open', + deliveredAt: old.sentAt || null, + }) + } } + comments = [...newest.values()] } const bundle = { @@ -77,7 +88,7 @@ const bundle = { name, fileName: path.basename(FILE), currentVersion: state.version || 1, - html, versions, reviews, + html, versions, comments, } const shell = fs.readFileSync(path.join(HERE, 'workspace.html'), 'utf8') @@ -104,6 +115,11 @@ const titled = out.replace(/[^<]*<\/title>/i, () => `<title>${esc(name)} if (titled === out) console.error('warning: no <title> to name — the Artifact will be filed under the workspace default') out = titled +/* Carry the Host profile in, the same as `serve` does. Without it the bundle + falls back to the default profile and a review shared from another host asks + the reviewer to copy their comments for the wrong agent. */ +out = withHost(out, loadHost(resolveHostId(args))) + const dest = path.resolve(args.out || path.join(DIR, `${NAME}-review.html`)) fs.mkdirSync(path.dirname(dest), { recursive: true }) fs.writeFileSync(dest, out) diff --git a/plugins/vstack/skills/review/assets/review-server.mjs b/plugins/vstack/skills/review/assets/review-server.mjs index 0c29e07..821e8b1 100644 --- a/plugins/vstack/skills/review/assets/review-server.mjs +++ b/plugins/vstack/skills/review/assets/review-server.mjs @@ -18,28 +18,33 @@ * * node review-server.mjs serve --file <page.html> [--port 7788] [--idle-timeout 90] [--no-open] * node review-server.mjs serve --app <url> [--name <slug>] [--start /path] [--port 7788] - * node review-server.mjs claim --file <page.html> --round r1 - * node review-server.mjs publish --file <page.html> --round r1 --label "…" [--addressed c1,c3] - * node review-server.mjs reply --file <page.html> --round r1 --comment <id> --text "…" + * node review-server.mjs watch --file <page.html> (blocks; hands over the open comments) + * node review-server.mjs publish --file <page.html> [--close c1,c3] [--label "…"] [--summary "…"] + * node review-server.mjs reply --file <page.html> --comment <id> --text "…" + * [--option "…" --option "…" [--recommend <n>]] * node review-server.mjs ack --file <page.html> --token <token> * node review-server.mjs share --file <page.html> --url <artifact-url> * node review-server.mjs status --file <page.html> - * node review-server.mjs check --file <page.html> (names a round nobody has claimed) - * node review-server.mjs watch --file <page.html> (blocks until there is something to do) * * Every command takes `--app <url>` or `--name <slug>` in place of `--file` when * the review is of a running app. * + * One list of comments, each open or closed, and the agent is the only one who + * closes. `watch` hands over every open comment and records that it went; + * whatever the agent does not close comes back on the next one. Nothing can + * refuse a close: an agent that took delivery can always finish. + * * State lives in a sibling directory, out of the way of the page: * <dir>/.vstack/local/review/<name>/ (live: <cwd>/.vstack/local/review/<name>/) - * state.json { name, version, app?, start? } + * state.json { name, version, file? | app?, start? } + * comments.json every comment for this review — the whole truth + * brief.md the open comments, rewritten on every delivery * versions/v<n>.html frozen copy of each published version * (live: the DOM as it stood when a review was sent) - * versions/v<n>.meta.json label, date, what it addressed - * reviews/v<n>/ annotations.json · feedback.json · feedback.md - * pending sentinel written on send, watched by the agent + * versions/v<n>.meta.json label and date — a snapshot to look at, nothing more + * reviews/v<n>/ only ever read: where a store filled by an older + * version keeps its comments * handshake a stream watcher waiting to be told its events land - * rounds/r<n>.json durable round membership and completion record * approved sentinel written on sign-off — the review is over * share sentinel — they want a shareable public link * url the live URL — present only while the server runs @@ -92,6 +97,26 @@ function parseArgs (argv) { } const args = parseArgs(process.argv.slice(2)) +/** Every value given for a flag that may be repeated, in the order typed — + `--option A --option B`. parseArgs keeps one value per flag, which is right + for every other flag there is. */ +function repeatedArg (flag) { + const argv = process.argv.slice(2), out = [] + for (let i = 0; i < argv.length; i++) { + if (argv[i] !== `--${flag}`) continue + const next = argv[i + 1] + if (next !== undefined && !next.startsWith('--')) { out.push(next); i++ } + } + return out +} + +/* The agent session this process acts for — `--session <id>`, supplied by the + Host adapter. The engine never knows how a host names its sessions; it only + records the identity it was given, so that a delivery binds to the session + whose watcher took it and `unanswered --session` can answer for one session + without implicating another standing in the same directory. */ +const SESSION = args.session && args.session !== true ? String(args.session) : null + /** Host profile for UI injection (serve). Other commands ignore it. */ let HOST_PROFILE = null try { HOST_PROFILE = loadHost(resolveHostId(args)) } catch (e) { @@ -131,7 +156,7 @@ if (LIVE) { console.error(' are rewritten to stay inside the proxy, but bot protection, a login wall or a') console.error(' strict CSRF check can still refuse it. If the site misbehaves, say so.') } -} else if (['watch', 'ack'].includes(args._) && (args.all === true || args.all === 'true')) { +} else if (['watch', 'ack', 'unanswered'].includes(args._) && (args.all === true || args.all === 'true')) { /* `watch --all` names no subject on purpose — it finds the live ones itself, so a session with several pages open arms one waiter instead of one each. */ DIR = process.cwd(); NAME = 'all'; STORE = workDir(DIR, TOOL.review) @@ -159,11 +184,12 @@ const P = { state: () => path.join(STORE, 'state.json'), versions: () => path.join(STORE, 'versions'), version: n => path.join(STORE, 'versions', `v${n}.html`), + /* Every comment for this review, in one list. Older stores kept a copy of a + comment in each version's directory; `review(n)` is only ever read now. */ + comments: () => path.join(STORE, 'comments.json'), review: n => path.join(STORE, 'reviews', `v${n}`), - rounds: () => path.join(STORE, 'rounds'), - round: id => path.join(STORE, 'rounds', `${id}.json`), + brief: () => path.join(STORE, 'brief.md'), lock: () => path.join(STORE, 'transition.lock'), - pending: () => path.join(STORE, 'pending'), handshake: () => path.join(STORE, 'handshake'), approved: () => path.join(STORE, 'approved'), share: () => path.join(STORE, 'share'), @@ -180,14 +206,19 @@ const writeJSON = (f, v) => { writeAtomic(f, JSON.stringify(v, null, 2) + '\n') } -let heldLock = null +/* A watcher writes into stores it did not start, so the lock is named by the + store it protects rather than by this process's own subject. */ +let heldLock = null, heldLockFile = null const lockWait = new Int32Array(new SharedArrayBuffer(4)) -function acquireStoreLock (timeout = 2500) { - fs.mkdirSync(STORE, { recursive: true }) +const lockFile = store => path.join(store, 'transition.lock') +function acquireStoreLock (store, timeout = 2500) { + fs.mkdirSync(store, { recursive: true }) + const file = lockFile(store) const until = Date.now() + timeout while (true) { try { - heldLock = fs.openSync(P.lock(), 'wx') + heldLock = fs.openSync(file, 'wx') + heldLockFile = file fs.writeFileSync(heldLock, `${process.pid}\n`) return } catch (error) { @@ -195,8 +226,8 @@ function acquireStoreLock (timeout = 2500) { try { // A killed process cannot clean up. A transition never legitimately // holds this lock for thirty seconds, so recover that orphan safely. - if (Date.now() - fs.statSync(P.lock()).mtimeMs > 30000) { - fs.rmSync(P.lock(), { force: true }) + if (Date.now() - fs.statSync(file).mtimeMs > 30000) { + fs.rmSync(file, { force: true }) continue } } catch {} @@ -209,10 +240,11 @@ function releaseStoreLock () { if (heldLock === null) return try { fs.closeSync(heldLock) } catch {} heldLock = null - try { fs.rmSync(P.lock(), { force: true }) } catch {} + try { fs.rmSync(heldLockFile, { force: true }) } catch {} + heldLockFile = null } -function withStoreLock (fn) { - acquireStoreLock() +function withStoreLock (fn, store = STORE) { + acquireStoreLock(store) try { return fn() } finally { releaseStoreLock() } } process.on('exit', releaseStoreLock) @@ -235,181 +267,248 @@ const appOrigin = () => (APP ? APP.origin : loadState().app || null) const loadState = () => readJSON(P.state(), { version: 0 }) const saveState = s => writeJSON(P.state(), s) -/** Review folders outlive snapshot history. Scan them directly so clearing old - * versions cannot make carried comments impossible to reply to or close. */ -function listReviewVersions () { - let files = [] - try { files = fs.readdirSync(path.join(STORE, 'reviews')) } catch { return [] } - return files.flatMap(file => { - const match = /^v(\d+)$/.exec(file) - return match ? [Number(match[1])] : [] - }).sort((a, b) => a - b) +/* ──────────────────────── the comment list ─────────────────────── + One list per review, and the only place a comment's state lives. A comment + is open or closed. Two timestamps say where it is between the reviewer and + the agent: `sentAt` is the reviewer letting go of it, which also freezes its + words; `deliveredAt` is the agent taking it, after which withdrawing it + leaves the record behind. `deliveredTo` names the session that took it — + whichever identity the last deliverer was started with — so what a session + owes is a recorded fact, not an inference from standing in the same + directory. Everything the workspace shows is derived from those. */ + +/** + * What a review is, read from its own store rather than from this process's + * flags. One watcher covers reviews it did not start, and it has to be able to + * hand their comments over and name the commands that answer them. + */ +function subjectOf (store) { + const state = readJSON(path.join(store, 'state.json'), {}) || {} + const live = !!state.app + const name = state.name || path.basename(store) + return { + store, state, live, name, + file: state.file || null, + origin: state.app || null, + flags: live ? `--name "${name}"` : `--file "${state.file || ''}"`, + comments: path.join(store, 'comments.json'), + brief: path.join(store, 'brief.md'), + } } +const here = () => subjectOf(STORE) + +const loadComments = (subject = here()) => + readJSON(subject.comments)?.comments || adoptOlderStore(subject.store) -function commentRecords (id) { - return listReviewVersions().flatMap(version => { - const file = path.join(P.review(version), 'annotations.json') - const saved = readJSON(file) - const comment = saved?.annotations?.find(a => a.id === id) - return comment ? [{ version, file, saved, comment }] : [] +function saveComments (comments, subject = here()) { + writeJSON(subject.comments, { + version: 1, updatedAt: new Date().toISOString(), comments, }) + return comments } -const latestComment = id => commentRecords(id).at(-1) || null +/** Fields the protocol owns. A client may write everything else on a comment it + * still holds, and none of these ever. */ +const OWNED = ['state', 'sentAt', 'deliveredAt', 'deliveredTo', 'dismissedAt'] + +const normaliseComment = c => ({ + ...c, + state: c.state === 'closed' ? 'closed' : 'open', + replies: c.replies || [], + sentAt: c.sentAt || null, + deliveredAt: c.deliveredAt || null, + deliveredTo: c.deliveredTo || null, +}) /** - * Where an agent's own writing about a comment belongs: the version the - * workspace has open. A comment the reviewer has not touched since the last - * publication still lives in an earlier review file, so it is copied forward - * first. Writing to the older copy instead answers where nobody is looking — - * the workspace reads its list from the current version. + * A store filled before the comment list existed keeps a copy of each comment + * in every version directory it appeared in. Read the newest copy of each and + * translate it: `addressed` and a reviewer's dismissal are both closed, and + * anything already sent has been in the agent's hands. + * + * Those files are left exactly where they are. A user's review is not migrated + * behind their back — the list is simply written alongside from now on. */ -function currentRecord (id) { - const version = loadState().version - const file = path.join(P.review(version), 'annotations.json') - const saved = readJSON(file) || { version, annotations: [] } - saved.annotations ||= [] - const here = saved.annotations.find(comment => comment.id === id) - if (here) return { version, file, saved, comment: here } - const earlier = latestComment(id) - if (!earlier) return null - const carried = { ...earlier.comment } - saved.annotations.push(carried) - return { version, file, saved, comment: carried, carriedFrom: earlier.version } -} - -function latestComments () { - const found = new Map() - for (const version of listReviewVersions()) { - const file = path.join(P.review(version), 'annotations.json') - const saved = readJSON(file) - for (const comment of saved?.annotations || []) found.set(comment.id, { version, file, saved, comment }) - } - return [...found.values()] -} - -/** A submitted comment revision is immutable for that round. Agent replies are - * a disposition, so only open comments are compared against this fingerprint. */ -function commentRevision (comment) { - const value = { - id: comment?.id || '', - status: comment?.status || 'open', - note: comment?.note || '', - replies: (comment?.replies || []).map(reply => ({ - by: reply.by || '', text: reply.text || '', at: reply.at || '', - })), - reopened: !!(comment?.reopened ?? comment?.reopenedAt), - wantsRevert: !!(comment?.wantsRevert ?? comment?.revert), +function adoptOlderStore (store = STORE) { + let dirs = [] + try { + dirs = fs.readdirSync(path.join(store, 'reviews')) + .flatMap(file => { const m = /^v(\d+)$/.exec(file); return m ? [Number(m[1])] : [] }) + .sort((a, b) => a - b) + } catch { return [] } + const newest = new Map() + for (const version of dirs) { + const saved = readJSON(path.join(store, 'reviews', `v${version}`, 'annotations.json')) + for (const old of saved?.annotations || []) newest.set(old.id, { old, version }) } - return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16) + return [...newest.values()].map(({ old, version }) => { + const { status, dismissed, reopenedAt, revert, held, fromVersion, ...rest } = old + return normaliseComment({ + ...rest, + seenAt: fromVersion || version, + state: status === 'addressed' || dismissed ? 'closed' : 'open', + sentAt: old.sentAt || null, + deliveredAt: old.sentAt || null, + }) + }) } -/* `cancelled` is still terminal here although nothing writes it any more: a - store filled before the Stop control was withdrawn can hold one, and reading - it as live would hand the agent a round the reviewer called off. */ -function loadActiveRound (state = loadState()) { - if (!state.activeRound) return null - const round = readJSON(P.round(state.activeRound)) - return round && !['completed', 'cancelled', 'approved'].includes(round.status) ? round : null -} +/** Open, and released by the reviewer — what a tick hands over. */ +const deliverable = comments => comments.filter(c => c.state === 'open' && c.sentAt) + +/** Has the reviewer said anything the agent has not been given yet? A comment + * it has never seen, or an answer written since it last took delivery. */ +const unseen = comment => !comment.deliveredAt || + (comment.replies || []).some(reply => reply.by === REVIEWER_ROLE && + Date.parse(reply.at || '') > Date.parse(comment.deliveredAt)) +const anythingWaiting = (subject = here()) => + deliverable(loadComments(subject)).some(unseen) + +/* ───────────────────────────── the brief ───────────────────────── + The open comments, written for the agent. It is rendered here rather than in + the workspace because the workspace does not know what a delivery is: a tick + hands over everything open, whenever each of them was written. */ + +const SCREENS = [ + { id: 'ultrawide', label: 'Ultrawide', width: 2560, height: 1440 }, + { id: 'desktop', label: 'Desktop', width: 1440, height: 900 }, + { id: 'tablet', label: 'Tablet', width: 834, height: 1112 }, + { id: 'phone', label: 'Phone', width: 390, height: 844 }, +] -function saveActiveRound (round) { - writeJSON(P.round(round.id), round) - const state = loadState() - state.activeRound = round.id - state.roundSeq = Math.max(Number(state.roundSeq) || 0, Number(String(round.id).replace(/^r/, '')) || 0) - saveState(state) - return round +/** An element written as its own opening tag — what to search the source for. */ +const elLine = anchor => '`<' + anchor.tag + (anchor.id ? ` id="${anchor.id}"` : '') + + (anchor.cls ? ` class="${anchor.cls}"` : '') + (anchor.role ? ` role="${anchor.role}"` : '') + '>`' + +/** Where a comment is, said the way a person would: the thing it is on first, + * the part of the page it lives in second, coordinates last. */ +function whereLine (comment) { + if (comment.kind === 'general') return 'the page as a whole — not attached to an element' + const geo = comment.kind === 'area' && comment.rect + ? `area ${Math.round(comment.rect.w)}×${Math.round(comment.rect.h)} at ${Math.round(comment.rect.x)},${Math.round(comment.rect.y)}` + : `at ${Math.round(comment.point?.x || 0)},${Math.round(comment.point?.y || 0)}` + const anchor = comment.anchor + if (!anchor) return `${geo}${comment.anchorText ? ` — on “${comment.anchorText}”` : ''}` + const words = anchor.text || anchor.label + const region = !anchor.region ? '' + : anchor.region.kind === 'region' ? (anchor.region.label ? ` inside “${anchor.region.label}”,` : '') + : ` inside ${anchor.region.kind}${anchor.region.label ? ` “${anchor.region.label}”` : ''},` + return `on ${elLine(anchor)}${words ? ` “${words}”` : ''},${region} ${geo}` } -function finishActiveRound (round, status, extra = {}) { - if (!round) return - const finished = { ...round, ...extra, status, finishedAt: new Date().toISOString() } - writeJSON(P.round(round.id), finished) - const state = loadState() - if (state.activeRound === round.id) delete state.activeRound - state.lastRound = { id: round.id, status, version: extra.publishedVersion || state.version } - saveState(state) - fs.rmSync(P.pending(), { force: true }) - return finished +/** A move, said as a place rather than a distance: the page reflows and the + * pixels stop being true, but the element it was dropped on does not. */ +function moveLine (comment) { + const delta = comment.delta || { dx: 0, dy: 0 } + const dragged = `dragged ${Math.abs(delta.dx)}px ${delta.dx >= 0 ? 'right' : 'left'} and ${Math.abs(delta.dy)}px ${delta.dy >= 0 ? 'down' : 'up'}` + const target = comment.target + if (!target?.anchor) return `somewhere else on the page — ${dragged}. Nothing was under the drop, so the direction is all they gave you.` + const words = target.anchor.text || target.anchor.label + const place = target.where === 'inside' ? 'into' : target.where === 'before' ? 'before' : 'after' + return `${place} ${elLine(target.anchor)}${words ? ` “${words}”` : ''} (${dragged})` } -function nextRound (version, comments, feedback) { - const state = loadState() - let round = loadActiveRound(state) - if (!round) { - const seq = (Number(state.roundSeq) || 0) + 1 - round = { - id: `r${seq}`, baseVersion: version, status: 'queued', - createdAt: new Date().toISOString(), comments: [], - } +const strikeLine = comment => comment.scope === 'text' + ? `the text “${String(comment.text || '').slice(0, 160)}” — remove those words, leave the rest of the element` + : 'this element and everything in it' + +const coversLine = comment => comment.covers.map(c => `\`<${c.tag}>\` “${c.text}”`).join(' · ') + +function renderBrief (subject, going, fresh) { + const L = [] + L.push(`# ${subject.live ? 'Live UI review' : 'Wireframe review'} — ${subject.name} · v${subject.state.version || 1}`) + L.push(`${going.length} open comment(s) — every one is a must` + + (fresh.size ? ` · ${fresh.size} new since you last looked` : '')) + L.push('') + if (subject.live) { + L.push(`These are comments on the app running at \`${subject.origin || subject.name}\` — change the source, ` + + 'not a mockup. Each comment says which **route** it was made on; the anchor names the ' + + 'element, which is what to search the codebase for.') + L.push('') + L.push('The reviewer is looking at the app right now. If it hot-reloads they will see your ' + + 'change as you make it, so land whole changes rather than half of one.') + } else { + L.push(`Apply these to \`${subject.file ? path.basename(subject.file) : subject.name}\`, then publish the next version.`) } - const members = new Map((round.comments || []).map(comment => [comment.id, comment])) - for (const comment of comments || []) { - members.set(comment.id, { id: comment.id, revision: commentRevision(comment) }) + if (going.some(comment => comment.kind === 'move' || comment.kind === 'strike')) { + L.push('') + L.push('Some of these were drawn on the page rather than written: **Move it** is an arrow ' + + 'from a thing to where it should go, and **Delete** is something struck out. They are ' + + 'instructions in their own right — a note on one adds to it, and no note means there was ' + + 'nothing to add.') } - round.comments = [...members.values()] - round.feedback = feedback - round.updatedAt = new Date().toISOString() - return saveActiveRound(round) -} - -/** Upgrade an in-flight review created by a pre-round-ledger server. This keeps - * a tool update or server restart from stranding feedback already on disk. */ -function migrateLegacyPending () { - const state = loadState() - const existing = loadActiveRound(state) - if (existing) return existing - const pending = readJSON(P.pending()) - if (!pending?.comments?.length) return null - const seq = (Number(state.roundSeq) || 0) + 1 - const round = { - id: `r${seq}`, - baseVersion: Number(pending.version) || state.version, - status: 'queued', - createdAt: pending.sentAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - feedback: pending.feedback || null, - comments: pending.comments.map(id => ({ - id, - revision: commentRevision(latestComment(id)?.comment || { id }), - })), - migrated: true, + L.push('') + + const bySize = {} + for (const comment of going) (bySize[comment.size || 'desktop'] ||= []).push(comment) + for (const screen of SCREENS) { + const list = bySize[screen.id] + if (!list?.length) continue + L.push(`## ${screen.label} — ${screen.width} × ${screen.height}`) + L.push('') + for (const comment of list) { + L.push(`### ${comment.id}${fresh.has(comment.id) ? ' · NEW' : ''}`) + if (!fresh.has(comment.id)) L.push('*You have had this one before and it is still open — carry on with it rather than starting again.*') + if (subject.live && comment.route) L.push(`**Route** \`${comment.route}\``) + L.push(`**Where** ${whereLine(comment)}`) + if (comment.kind === 'move') L.push(`**Move it** ${moveLine(comment)}`) + if (comment.kind === 'strike') L.push(`**Delete** ${strikeLine(comment)}`) + if (comment.covers?.length) L.push(`**Covers** ${coversLine(comment)}`) + if (comment.note) L.push(comment.note) + for (const reply of comment.replies || []) { + L.push('') + L.push(`> **${reply.by === REVIEWER_ROLE ? 'They replied' : 'You asked'}:** ${reply.text}`) + // The options you offered, so a question that comes back reads as the + // question you actually asked rather than only its opening line. + for (const option of reply.options || []) { + L.push(`> - ${option.text}${option.recommended ? ' *(you recommended this)*' : ''}`) + } + } + L.push('') + } } - saveActiveRound(round) - writeJSON(P.pending(), { ...pending, roundId: round.id }) - return round + L.push('---') + L.push('Close what you have done. Anything you do not name stays open and comes back next time,') + L.push('so ask about whatever is unclear instead of guessing:') + L.push('```bash') + L.push(`node review-server.mjs publish ${subject.flags} --close <ids> --label "<what changed>" \\`) + L.push(` --summary "<the rest of what you would tell them>" # optional, shown in the workspace`) + L.push(`node review-server.mjs reply ${subject.flags} --comment <id> --text "<your question>"`) + L.push('```') + return L.join('\n') + '\n' } -/* "Linked" must mean someone will act on what the reviewer sends, not that a - watch process is alive. The heartbeat proves the process; a round nobody - claims within this window proves its events go unread — a watcher started - with the wrong host op, a dead session, and a killed watcher all look the - same from here. 90s gives an agent mid-turn time to reach the claim. */ -/* A round leaves the queue only by being claimed, so its wait is measured from - when it was created. Nothing else about the round decides this: a heartbeat - with a round nobody has picked up is the state this exists to catch. */ -const CLAIM_STALL_MS = 90_000 -const roundStalled = round => !!round && round.status === 'queued' && - Date.now() - Date.parse(round.createdAt || '') > CLAIM_STALL_MS -const agentListening = () => someoneWatching() && !roundStalled(loadActiveRound()) - -function roundSummary (round) { - if (!round) return null - return { - id: round.id, status: round.status, baseVersion: round.baseVersion, - comments: (round.comments || []).map(comment => comment.id), - createdAt: round.createdAt, claimedAt: round.claimedAt || null, - stalled: roundStalled(round), - } +/** + * Hand every open comment to the agent, and record that it went. + * + * All of them, every time — not only the new ones. A comment the agent skipped + * comes back on the next tick, so the only way to be rid of one is to close it, + * and nothing can be forgotten by being missed. What is new since the last + * delivery is marked as such, which is a hint for where to look rather than a + * filter on what arrives. + */ +function deliver (subject = here()) { + const comments = loadComments(subject) + const going = deliverable(comments) + const fresh = new Set(going.filter(comment => !comment.deliveredAt).map(comment => comment.id)) + const at = new Date().toISOString() + /* The latest delivery owns the round: a comment handed over again binds to + whoever took it this time, which is also how a review adopted after its + session died changes hands. A watcher given no identity records none. */ + for (const comment of going) { comment.deliveredAt = at; comment.deliveredTo = SESSION } + saveComments(comments, subject) + fs.mkdirSync(subject.store, { recursive: true }) + writeAtomic(subject.brief, renderBrief(subject, going, fresh)) + return { going, fresh } } -function unresolvedComments () { - return latestComments() - .map(record => record.comment) - .filter(comment => !comment.dismissed && comment.status !== 'addressed' && String(comment.note || '').trim()) - .map(comment => ({ id: comment.id, note: comment.note, status: comment.status || 'open' })) -} +/* Presence is the watcher: it is the loop that takes delivery, so a live + heartbeat is a session that will be handed the next comment written. */ +const agentListening = () => someoneWatching() + +const openComments = () => loadComments() + .filter(comment => comment.state === 'open' && String(comment.note || '').trim()) + .map(comment => ({ id: comment.id, note: comment.note, sent: !!comment.sentAt })) /* A published round is its meta file. The frozen html beside it is optional — a live round only has one once a review has been sent from it, and a round @@ -430,108 +529,141 @@ function listVersions () { /* ──────────────────────────── publish ───────────────────────────── */ /** - * Freeze the working file as the NEXT version and make it current, so the - * version the workspace names always has a frozen copy behind it. - * --replace overwrites the current version instead (for a version nobody has - * reviewed yet). + * The agent's answer: close what is done, and snapshot the version it did it in. + * + * The two halves are independent. `--close` closes exactly the comments it + * names and nothing else — anything left unnamed stays open and comes back on + * the next tick, so there is no coverage to satisfy and nothing to account for. + * `--label` freezes the page as the next version. Neither can be refused for + * anything the reviewer has done in the meantime: an agent that took delivery + * can always finish. + * + * --replace overwrites the current version instead of adding one, for a version + * nobody has reviewed yet. */ function cmdPublish (quiet) { - let state = loadState() - const active = loadActiveRound(state) || migrateLegacyPending() - const requestedRound = args.round && args.round !== true ? String(args.round) : null - - // Retrying a completed command is a no-op, not another version. - if (!active && requestedRound) { - const finished = readJSON(P.round(requestedRound)) - if (finished?.status === 'completed') { - if (!quiet) console.log(`Round ${requestedRound} already published as v${finished.publishedVersion}`) - return - } - console.error(`No active round ${requestedRound}`) - process.exit(2) - } - - const addressed = [...new Set(String(args.addressed || '').split(',').map(s => s.trim()).filter(Boolean))] - if (active) { + const ids = [...new Set(String(args.close ?? args.addressed ?? '').split(',').map(s => s.trim()).filter(Boolean))] + const label = args.label && args.label !== true ? String(args.label) : null + /* The label names the version in one line; the summary is what the agent + would say in chat about the round it just finished. The workspace shows it + where the news lands, so a reviewer who is not reading the terminal still + gets the account of what changed. */ + const summary = args.summary && args.summary !== true ? String(args.summary).trim() : null + /* A version is a frozen copy of the page under review, and a running app has + no such thing: what a capture of one produces is a likeness with its scripts + stripped and half its styling missing, which is worse than not offering it. + So a live review has no versions — only comments. */ + const snapshot = !LIVE && (!!label || (!ids.length && args.close === undefined && args.addressed === undefined)) + + const comments = loadComments() + if (ids.length) { + const byId = new Map(comments.map(comment => [comment.id, comment])) const errors = [] - if (!requestedRound) errors.push(`include --round ${active.id}`) - else if (requestedRound !== active.id) errors.push(`active round is ${active.id}, not ${requestedRound}`) - if (active.status !== 'active') errors.push(`claim ${active.id} before publishing it`) - if (args.replace === true || args.replace === 'true') errors.push('--replace cannot complete an active review round') - - const members = new Map((active.comments || []).map(comment => [comment.id, comment])) - for (const id of addressed) if (!members.has(id)) errors.push(`${id} does not belong to ${active.id}`) - for (const member of members.values()) { - const found = latestComment(member.id) - const comment = found?.comment - if (!comment) { errors.push(`${member.id} no longer exists`); continue } - if (comment.dismissed || comment.status === 'addressed' || comment.status === 'question') continue - if (!addressed.includes(member.id)) { - errors.push(`${member.id} is still open`) - continue - } - if (commentRevision(comment) !== member.revision) { - errors.push(`${member.id} changed after ${active.id} was submitted; collect the updated review before closing it`) - } - } - for (const id of addressed) { - const status = latestComment(id)?.comment?.status - if (status && status !== 'open') errors.push(`${id} is ${status}, not open`) + for (const id of ids) { + const comment = byId.get(id) + if (!comment) errors.push(`${id} is not a comment on this review`) + else if (!comment.sentAt) errors.push(`${id} has not been sent yet`) } + // Closing is all or nothing, so a typo costs a retry rather than half a round. if (errors.length) { - console.error(`Cannot publish ${active.id}:`) + console.error('Cannot close:') for (const error of errors) console.error(` - ${error}`) process.exit(2) } - } else if (addressed.length) { - console.error('Cannot mark comments addressed without an active review round') - process.exit(2) - } - - // Validation is complete. Nothing above this line mutates a version or a - // comment, so a rejected completion cannot leave a half-published round. - const replace = args.replace === true || args.replace === 'true' - const n = replace ? Math.max(1, state.version) : state.version + 1 - fs.mkdirSync(P.versions(), { recursive: true }) - if (!LIVE) fs.copyFileSync(FILE, P.version(n)) - - // Feedback carries items forward, so patch every stored occurrence. Review - // directories are the source here, not version history, which may be cleared. - for (const id of addressed) { - for (const record of commentRecords(id)) { - if (record.comment.status !== 'open') continue - record.comment.status = 'addressed' - delete record.comment.reopenedAt - delete record.comment.revert - writeJSON(record.file, record.saved) + // Closing what is already closed is a no-op, so a retried command is safe. + const at = new Date().toISOString() + for (const id of ids) { + const comment = byId.get(id) + if (comment.state === 'closed') continue + comment.state = 'closed' + // What was just closed is what the reviewer wants to look at; what was + // closed a while ago is a record. The panel reads that off this. + comment.closedAt = at } + saveComments(comments) } - const prev = readJSON(path.join(P.versions(), `v${n}.meta.json`), {}) || {} - writeJSON(path.join(P.versions(), `v${n}.meta.json`), { - n, - label: args.label || prev.label || (n === 1 ? (LIVE ? 'The app as it stands' : 'Initial version') : `${LIVE ? 'Round' : 'Version'} ${n}`), - date: new Date().toISOString(), - addressed, - ...(active ? { round: active.id } : {}), - }) + let n = loadState().version + if (LIVE && !n) { + // Live has one version and it is the app itself, so the number never moves. + const state = loadState() + state.version = n = 1 + state.name = pageName() + saveState(state) + } + if (snapshot) { + const replace = args.replace === true || args.replace === 'true' + n = replace ? Math.max(1, n) : n + 1 + fs.mkdirSync(P.versions(), { recursive: true }) + if (!LIVE) fs.copyFileSync(FILE, P.version(n)) + const prev = readJSON(path.join(P.versions(), `v${n}.meta.json`), {}) || {} + writeJSON(path.join(P.versions(), `v${n}.meta.json`), { + n, + label: label || prev.label || (n === 1 ? (LIVE ? 'The app as it stands' : 'Initial version') : `Version ${n}`), + date: new Date().toISOString(), + }) + const state = loadState() + state.version = n + state.name = pageName() + saveState(state) + } + /* One summary at a time, and it belongs to the round that has just landed — + a publish that carries none clears the last one rather than leaving the + workspace showing an account of work that is now two rounds old. Written + for a live review too, which has no version to hang it on. */ + if (ids.length || snapshot) { + const state = loadState() + state.summary = summary ? { text: summary, at: new Date().toISOString() } : null + saveState(state) + } - state = loadState() - state.version = n - state.name = pageName() - saveState(state) - if (active) { - const outcomes = Object.fromEntries((active.comments || []).map(member => { - const comment = latestComment(member.id)?.comment - return [member.id, addressed.includes(member.id) ? 'addressed' - : comment?.status === 'question' ? 'waiting_for_reviewer' - : comment?.dismissed ? 'dismissed' : comment?.status || 'unknown'] - })) - finishActiveRound(active, 'completed', { publishedVersion: n, addressed, outcomes }) + if (!quiet) { + console.log([ + snapshot ? `Published v${n}` : null, + ids.length ? `closed ${ids.length} comment(s)` : null, + ].filter(Boolean).join(' — ') || 'Nothing to do') + /* Said here because here is where the agent believes it has finished. The + tick will not raise these again on its own — it wakes for what the + reviewer says, and they have said it already. */ + const left = loadComments().filter(comment => comment.state === 'open' && comment.deliveredAt) + if (left.length) { + console.log(`${left.length} comment(s) you were given are still open: ${left.map(c => c.id).join(', ')}`) + console.log('Close them, or reply asking about them — leaving one silently leaves it on the reviewer.') + } } + touch() +} - if (!quiet) console.log(`Published v${n}${active ? ` — completed ${active.id}` : ''}${addressed.length ? ` — ${addressed.length} item(s) marked addressed` : ''}`) +/** + * Start the review over. Everything a version of this tool wrote about it goes: + * the comments, the brief, the snapshots, and the directories a store filled by + * an older version keeps its comments in — which would otherwise be adopted + * straight back on the next read. What the review *is* stays: the page or app + * under it, and its name. + * + * It is a command as well as a button because the reason to want it is a tool + * update that changed what a review keeps on disk, and that is exactly when the + * workspace may not be the thing that can ask for it. + */ +function cmdReset (quiet) { + saveComments([]) + fs.rmSync(P.brief(), { force: true }) + fs.rmSync(path.join(STORE, 'reviews'), { recursive: true, force: true }) + fs.rmSync(P.versions(), { recursive: true, force: true }) + fs.rmSync(P.approved(), { force: true }) + fs.rmSync(P.share(), { force: true }) + const state = loadState() + saveState({ + name: state.name, version: 0, + ...(state.file ? { file: state.file } : {}), + ...(state.app ? { app: state.app, start: state.start } : {}), + }) + // A file review with no version has nothing for the workspace to show, so the + // page as it stands becomes v1 again. A live review has no versions at all. + cmdPublish(true) + console.log('\n⟲ Reset — the review starts again at v1') touch() + if (!quiet) console.log('Every comment and version for this review is gone.') } /** @@ -546,64 +678,36 @@ function cmdReply () { console.error('Need --comment <id> --text "…"') process.exit(1) } - const active = loadActiveRound() || migrateLegacyPending() - const requestedRound = args.round && args.round !== true ? String(args.round) : null - if (active) { - if (!requestedRound || requestedRound !== active.id) { - console.error(`Reply belongs to active round ${active.id}; include --round ${active.id}`) - process.exit(2) - } - if (active.status !== 'active') { - console.error(`Claim ${active.id} before replying to it`) - process.exit(2) - } - if (!(active.comments || []).some(comment => comment.id === id)) { - console.error(`${id} does not belong to ${active.id}`) - process.exit(2) - } + /* A question the reviewer answers by picking rather than by typing. The + options are offered on the comment, one of them can be marked as the one + you would take, and the box to type something else is still there — a + choice you did not think of is the whole reason the question was asked. */ + const options = repeatedArg('option') + const recommend = args.recommend === undefined ? 0 : Number(args.recommend) + if (options.length === 1) { + console.error('A choice needs at least two --option values') + process.exit(2) + } + if (options.length && (!Number.isInteger(recommend) || recommend < 0 || recommend > options.length)) { + console.error(`--recommend must be between 1 and ${options.length}, or left out`) + process.exit(2) } - const record = currentRecord(id) - if (!record) { console.error(`No comment ${id} found`); process.exit(1) } - const target = record.comment + const comments = loadComments() + const target = comments.find(comment => comment.id === id) + if (!target) { console.error(`No comment ${id} found`); process.exit(1) } + // Asking is not a state: the comment stays open, and stays in the next tick + // until it is closed. Whether it is waiting on the reviewer is written in the + // thread — the last word being the agent's — not in a second field that can + // disagree with it. target.replies = (target.replies || []).concat({ by: AGENT_ROLE, text, at: new Date().toISOString(), + ...(options.length + ? { options: options.map((option, i) => ({ text: option, recommended: i + 1 === recommend })) } + : {}), }) - if (args.status !== 'open') target.status = 'question' - record.saved.version = record.version - record.saved.updatedAt = new Date().toISOString() - writeJSON(record.file, record.saved) - - // A round made entirely of questions/dismissals needs no empty publication. - // Close its machine-owned work state as soon as no member remains open. - if (active) { - const outcomes = Object.fromEntries((active.comments || []).map(member => { - const comment = latestComment(member.id)?.comment - return [member.id, comment?.dismissed ? 'dismissed' : comment?.status || 'missing'] - })) - if (Object.values(outcomes).every(outcome => ['question', 'addressed', 'dismissed'].includes(outcome))) { - finishActiveRound(active, 'completed', { outcomes }) - } - } - console.log(`Replied to ${id} on v${record.version}${record.carriedFrom ? ` (carried forward from v${record.carriedFrom})` : ''} — the reviewer will see it on the comment`) - touch() -} - -/** Atomically acknowledge delivery without deleting the durable round ledger. */ -function cmdClaim () { - const round = loadActiveRound() || migrateLegacyPending() - if (!round) { console.error('No active review round to claim'); process.exit(2) } - const requested = args.round && args.round !== true ? String(args.round) : null - if (!requested || requested !== round.id) { - if (!requested) console.error(`Include --round ${round.id}`) - else console.error(`Active round is ${round.id}, not ${requested}`) - process.exit(2) - } - round.status = 'active' - round.claimedAt ||= new Date().toISOString() - round.lastClaimedAt = new Date().toISOString() - saveActiveRound(round) - fs.rmSync(P.pending(), { force: true }) - console.log(`Claimed ${round.id} — ${(round.comments || []).length} comment(s) · ${round.feedback}`) + saveComments(comments) + console.log(`Replied to ${id} — the reviewer will see it on the comment` + + (options.length ? ` with ${options.length} options to pick from` : '')) touch() } @@ -627,21 +731,6 @@ function cmdShare () { touch() } -/** - * "Is anything waiting on me?" — one cheap call, made between steps of a round. - * A round sitting in the queue is named here and nothing suppresses it: an agent - * asking and being told nothing, twice, while six comments sat queued is exactly - * how a broken watcher stays broken. Exit is always 0. - */ -function cmdCheck () { - const waiting = loadActiveRound() - if (waiting?.status === 'queued') { - console.log(`carry on — but ${waiting.id} (${(waiting.comments || []).length} comment(s), sent ${waiting.createdAt}) is waiting unclaimed.`) - console.log(`Claim it: node review-server.mjs claim ${SUBJECT} --round ${waiting.id}`) - } else console.log('carry on') - process.exit(0) -} - /** * Wait for the reviewer, and say so on the page while waiting. * @@ -740,8 +829,13 @@ function liveStores (from = process.cwd(), depth = 5) { } } walk(from, depth) - // A store found both ways is one review, so compare resolved paths. - return [...new Set([...found, ...pointedStores(from)].map(store => path.resolve(store)))] + /* A store found both ways is one review, so compare real paths rather than + resolved ones: the walk starts from `process.cwd()`, which has its symlinks + collapsed already, while `.serving` records the path the server was given. + Under a symlinked prefix — `/tmp` and `/var` on macOS — the same directory + otherwise arrives under two names and every caller sees it twice. */ + const realPath = store => { try { return fs.realpathSync(store) } catch { return path.resolve(store) } } + return [...new Set([...found, ...pointedStores(from)].map(realPath))] } /* How long a stream watcher waits to be told its events are being read. Long @@ -798,7 +892,7 @@ function cmdAck () { * node review-server.mjs watch --all --stream */ async function cmdStream (stores, label, all, subjectFlags) { - const seen = new Map(stores.map(s => [s, { sent: null, flags: new Set(), replies: repliesIn(s) }])) + const seen = new Map(stores.map(s => [s, { flags: new Set() }])) const say = line => { process.stdout.write(line + '\n') } say(`WATCHING ${stores.length} review(s): ${stores.map(label).join(', ')}`) @@ -884,20 +978,16 @@ async function cmdStream (stores, label, all, subjectFlags) { } else was.flags.delete(file) } - const brief = readJSON(at('pending')) - if (brief && brief.sentAt !== was.sent) { - was.sent = brief.sentAt - say(`REVIEW ${label(store)} · ${brief.roundId || 'legacy round'} · ${brief.counts?.total ?? '?'} comment(s) · ${brief.feedback}`) + /* The tick. Anything the reviewer has written and not had back — a new + comment, or an answer on one already in hand — is handed over here, and + everything still open goes with it. A reply needs no separate event: + it is the same comment, coming round again with more said on it. */ + const subject = subjectOf(store) + if (anythingWaiting(subject)) { + const { going, fresh } = withStoreLock(() => deliver(subject), store) + say(`REVIEW ${label(store)} · ${going.length} open` + + (fresh.size ? `, ${fresh.size} new` : '') + ` · ${subject.brief}`) } - - // A reply is the other thing that waits on the agent, and it writes no - // sentinel — answering a question just lands in annotations.json. - const now = repliesIn(store) - for (const [key, cur] of now) { - if ((was.replies.get(key)?.n || 0) >= cur.n) continue - say(`REPLIED ${label(store)} · ${key} · "${(cur.last || '').replace(/\s+/g, ' ').slice(0, 100)}"`) - } - was.replies = now } /* A review opened after this started should join it. Otherwise "one watcher @@ -907,8 +997,11 @@ async function cmdStream (stores, label, all, subjectFlags) { if (all) { for (const store of liveStores()) { if (seen.has(store)) continue + /* Not covered here and heartbeating anyway: another session's watcher + has it, and it joins this one only once that heartbeat is gone. */ + if (watchingRecently(inStore(store, 'watching'))) continue stores.push(store) - seen.set(store, { sent: null, flags: new Set(), replies: repliesIn(store) }) + seen.set(store, { flags: new Set() }) say(`OPENED ${label(store)} · now watching ${stores.length} review(s)`) } // A review that arrives after the handshake is what makes the link real. @@ -926,20 +1019,6 @@ async function cmdStream (stores, label, all, subjectFlags) { } } -/** Every reviewer reply in a store, as `v<n>/<id>` → { n, last }. */ -function repliesIn (store) { - const out = new Map() - let vs = [] - try { vs = fs.readdirSync(path.join(store, 'reviews')) } catch { return out } - for (const v of vs) { - for (const a of readJSON(path.join(store, 'reviews', v, 'annotations.json'))?.annotations || []) { - const mine = (a.replies || []).filter(r => r.by === REVIEWER_ROLE) - if (mine.length) out.set(`${v}/${a.id}`, { n: mine.length, last: mine.at(-1)?.text || '' }) - } - } - return out -} - async function cmdWatch () { // `--file` may be given more than once; parseArgs keeps only the last, so // read them off the raw argv. @@ -948,7 +1027,13 @@ async function cmdWatch () { // --all and --file combine: everything live in the project, plus anything // living outside it that you name. const all = args.all === true || args.all === 'true' - let stores = [...(all ? liveStores() : []), ...many.map(storeFor)] + /* A fresh heartbeat is another session's watcher, and covering the review + anyway would hand the same comment to two sessions. So the sweep leaves a + claimed store alone — it is found again the moment its watcher stops. A + store named with `--file` is covered regardless: naming it is a deliberate + takeover, which is how a review is adopted from a watcher that is stuck. */ + const unclaimed = store => !watchingRecently(inStore(store, 'watching')) + let stores = [...(all ? liveStores().filter(unclaimed) : []), ...many.map(storeFor)] // Named subjects only. Never fall back to the placeholder STORE from // `watch --all` (cwd/.vstack/local/review) — that path is not a review store, and // treating it as one exits the stream the moment it sees no `url` file @@ -986,9 +1071,9 @@ async function cmdWatch () { review nobody is reading. So the last thing printed is the command that puts it back. Prefer `watch --stream` via Host op watch_stream. */ const rearm = `node "${process.argv[1]}" ${process.argv.slice(2).join(' ')}` - const done = (what, store, file) => { + const done = (what, store, file, detail = '') => { stopBeating() - console.log(`${what} ${label(store)}`) + console.log(`${what} ${label(store)}${detail}`) if (file) { try { console.log(fs.readFileSync(file, 'utf8')) } catch {} } console.log(`\nThis one-shot watch has now ended. Either restart it:\n ${rearm}`) console.log(`or use the streaming form, which does not end:\n ${rearm} --stream`) @@ -1003,7 +1088,12 @@ async function cmdWatch () { const at = n => inStore(store, n) if (fs.existsSync(at('approved'))) return done('APPROVED', store, at('approved')) if (fs.existsSync(at('share'))) return done('SHARE', store, at('share')) - if (fs.existsSync(at('pending'))) return done('REVIEW', store, at('pending')) + const subject = subjectOf(store) + if (anythingWaiting(subject)) { + const { going, fresh } = withStoreLock(() => deliver(subject), store) + return done('REVIEW', store, subject.brief, + ` · ${going.length} open${fresh.size ? `, ${fresh.size} new` : ''}`) + } if (!fs.existsSync(at('url'))) { console.log(`CLOSED ${label(store)} — the tab went away`) fs.rmSync(at('watching'), { force: true }) @@ -1032,14 +1122,88 @@ function cmdStatus () { name: pageName(), version: state.version, versions: listVersions().map(v => `v${v.n}: ${v.label}`), - activeRound: roundSummary(loadActiveRound(state)), - pendingReview: fs.existsSync(P.pending()) ? readJSON(P.pending(), {}) : null, + comments: loadComments().map(comment => ({ + id: comment.id, + state: comment.state, + where: comment.sentAt ? (comment.deliveredAt ? 'with the agent' : 'queued') : 'still being written', + note: comment.note, + })), approved: fs.existsSync(P.approved()) ? readJSON(P.approved(), {}) : null, shareRequest: fs.existsSync(P.share()) ? readJSON(P.share(), {}) : null, shareUrl: loadState().shareUrl || null, }, null, 2)) } +/* ────────────────────────── unanswered ─────────────────────────── */ + +/** + * A comment the agent was handed and has said nothing about since. + * + * A delivered comment is answered by closing it or by replying to it. One that + * has neither is a round that stopped halfway, and nothing else in the protocol + * notices: the next tick only fires when the reviewer writes again, so an + * unanswered comment sits there for as long as they stay quiet. + * + * It is settled by comparing what the agent has said against what it was given, + * not against the delivery itself: every tick re-stamps `deliveredAt` on every + * open comment, so a comment the agent asked a question about would fall behind + * its own delivery as soon as the reviewer wrote anything at all. + */ +const unanswered = comment => { + if (comment.state !== 'open' || !comment.deliveredAt) return false + const when = reply => Date.parse(reply.at || '') || 0 + const delivered = Date.parse(comment.deliveredAt) + const latest = pick => Math.max(0, ...(comment.replies || []).filter(pick).map(when)) + // The reviewer's last word that the agent was actually handed. Anything + // written since is waiting for the next tick rather than for the agent. + const asked = latest(reply => reply.by === REVIEWER_ROLE && when(reply) <= delivered) + const answered = latest(reply => reply.by !== REVIEWER_ROLE) + return answered <= asked +} + +const oneLine = note => { + const text = String(note || '').replace(/\s+/g, ' ').trim() + return text.length > 72 ? text.slice(0, 71) + '…' : text +} + +/** + * What the agent still owes, said as the commands that settle it. Exits 1 when + * a round is unfinished, so a Host that can gate the end of a turn holds the + * session open until the round is handed back. + * + * `--all` reads every review with a server behind it, which is also the test + * for whether a round is in flight at all: a review whose tab has gone is over, + * and there is nothing left to owe. + * + * `--session <id>` asks for one session's debt and no one else's: only comments + * whose delivery was recorded for that id count. Without it, every unanswered + * comment counts, whoever took it — the form for a person asking after the + * review rather than a gate asking after itself. A delivery recorded with no + * session is nobody's to be gated on: naming it to a session that may never + * have seen it invites that session to close another's round, and the failure + * this command must not have is holding the wrong turn open. + */ +function cmdUnanswered () { + const stores = args.all === true || args.all === 'true' ? liveStores() : [STORE] + const bin = process.argv[1] + let owing = 0 + for (const store of stores) { + const subject = subjectOf(store) + const owed = loadComments(subject).filter(comment => + unanswered(comment) && (!SESSION || comment.deliveredTo === SESSION)) + if (!owed.length) continue + owing += owed.length + const them = owed.length > 1 ? 'them' : 'it' + console.log(`Review "${subject.name}" — you took delivery of ${owed.length} comment${owed.length > 1 ? 's' : ''} and have not answered ${them}:`) + for (const comment of owed) console.log(` ${comment.id} ${oneLine(comment.note)}`) + console.log(`\nDo what each one asks and close it, or reply to ask what it means:`) + console.log(` node "${bin}" publish ${subject.flags} --close ${owed.map(comment => comment.id).join(',')} --label "what changed"`) + console.log(` node "${bin}" reply ${subject.flags} --comment ${owed[0].id} --text "your question"`) + } + if (!owing) console.log('Nothing outstanding — every comment you were handed is closed or answered.') + process.exit(owing ? 1 : 0) +} + /* ───────────────────────────── serve ────────────────────────────── */ const clients = new Set() @@ -1086,12 +1250,6 @@ function readBody (req) { function payload () { const state = loadState() const versions = listVersions() - const activeRound = loadActiveRound(state) - const reviews = {} - for (const version of new Set([...listReviewVersions(), state.version])) { - const saved = readJSON(path.join(P.review(version), 'annotations.json')) - if (saved) reviews[version] = saved - } return { mode: LIVE ? 'live' : 'local', // What this server is on now. A tab opened before an update still holds the @@ -1103,23 +1261,25 @@ function payload () { base: BASE, startPath: state.start || '/', currentVersion: state.version, + // What the agent said about the round that just landed, if it said anything. + summary: state.summary || null, // A live review has no single document to hand over — the app serves it. html: LIVE ? '' : fs.readFileSync(FILE, 'utf8'), - versions, reviews, + versions, + /* The whole list, every time. A comment carries where it is — written, + queued, with the agent — so a reload or a second tab reads the same + review as the tab that wrote it, with nothing to reconstruct. What the + reviewer took off the list is the one thing left out: the record stays on + disk so the agent holding it can still close it. */ + comments: loadComments().filter(comment => !comment.dismissedAt), shareUrl: state.shareUrl || null, shareVersion: state.shareVersion || null, sharePending: fs.existsSync(P.share()), historyClearedAt: state.historyClearedAt || null, - /* Whether a round is out, and which comments are in it. The workspace used - to know this only because it was the tab that pressed Send — so a reload, - or a second tab, showed a review where nothing was happening. */ - pendingReview: fs.existsSync(P.pending()) ? readJSON(P.pending(), {}) : null, - activeReview: roundSummary(activeRound), /* Whether an agent session is actually waiting on this review. The link dot used to say "Linked" whenever the page could reach this server, which is a fact about the browser and the file server — not about anyone being - there to read what you send. A live heartbeat with a round sitting - unclaimed is the same lie one layer up, so that drops it too. */ + there to read what you send. */ watching: agentListening(), } } @@ -1135,20 +1295,62 @@ function payload () { * knows nothing of what the first just wrote. So an id the client left out is * kept. Removing a comment is `dismissed`, which is a field, not an absence. */ -function mergeIncoming (n, incoming) { - const stored = readJSON(path.join(P.review(n), 'annotations.json'))?.annotations - if (!stored?.length) return incoming - const byId = new Map(incoming.map(a => [a.id, a])) - const merged = stored.map(prev => { - const a = byId.get(prev.id) - if (!a) return prev - const next = { ...a } - if ((prev.replies || []).length > (a.replies || []).length) next.replies = prev.replies - if (prev.status === 'addressed' && a.status !== 'addressed' && !a.reopenedAt) next.status = 'addressed' - return next - }) - const kept = new Set(stored.map(a => a.id)) - return merged.concat(incoming.filter(a => !kept.has(a.id))) +/** A thread is an append-only log, so two writers can only ever add to it and + * the union of what they each hold is the whole of it. No copy is authoritative + * and none can lose a line by being stale. */ +function mergeReplies (stored = [], incoming = []) { + const byKey = new Map() + for (const reply of [...stored, ...incoming]) { + byKey.set(`${reply.by}|${reply.at}|${reply.text}`, reply) + } + return [...byKey.values()].sort((a, b) => Date.parse(a.at || 0) - Date.parse(b.at || 0)) +} + +/** + * Take what the workspace holds, and keep what it is not allowed to change. + * + * A comment the reviewer has sent is frozen: its words are what the agent was + * given, so only the thread may still grow on it. Before it is sent it is still + * theirs to rewrite. Either way the protocol's own fields are never the + * client's to set — a save cannot close a comment, un-send it, or say it was + * delivered — and a comment the payload does not mention is left alone, because + * a save says what one tab holds, not what the review contains. + */ +function acceptFromReviewer (incoming) { + const comments = loadComments() + const byId = new Map(comments.map(comment => [comment.id, comment])) + const at = new Date().toISOString() + for (const raw of incoming) { + if (!raw?.id) continue + const stored = byId.get(raw.id) + if (!stored) { + const { state, deliveredAt, deliveredTo, ...rest } = raw + const fresh = normaliseComment({ ...rest, state: 'open', deliveredAt: null, sentAt: raw.sentAt ? at : null }) + comments.push(fresh) + byId.set(fresh.id, fresh) + continue + } + const replies = mergeReplies(stored.replies, raw.replies) + const answered = replies.length > (stored.replies || []).length && + replies.at(-1)?.by === REVIEWER_ROLE + // The words are frozen once they are sent; before that the comment is still + // a draft and the reviewer may rewrite it however they like. + if (!stored.sentAt) { + for (const [key, value] of Object.entries(raw)) { + if (!OWNED.includes(key) && key !== 'replies') stored[key] = value + } + } + stored.replies = replies + if (!stored.sentAt && raw.sentAt) stored.sentAt = at + // Answering something called done says it is not done. It is the reviewer's + // only way back in, and it needs no separate control. Saying anything on a + // comment puts it back on the list, including one taken off it. + if (answered && stored.state === 'closed') { + stored.state = 'open' + stored.dismissedAt = null + } + } + return saveComments(comments) } /** Keep the current snapshot, but remove every earlier snapshot. Comments stay @@ -1385,31 +1587,100 @@ font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif;color:#667;backgr * no file to freeze, so the workspace hands one up: it is what the timeline * scrubs back to, and what gets published when they ask for a shareable link. */ - if (p === '/api/snapshot' && req.method === 'POST') { - // File reviews freeze the file themselves; accepting a body here would let - // a stray POST overwrite a published version. - if (!LIVE) return sendJSON(res, 404, { error: 'not a live review' }) + if (p === '/api/comments' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}') - const n = Number(body.version) || loadState().version - if (!body.html) return sendJSON(res, 400, { error: 'no html' }) return withStoreLock(() => { - fs.mkdirSync(P.versions(), { recursive: true }) - fs.writeFileSync(P.version(n), String(body.html)) - const meta = readJSON(path.join(P.versions(), `v${n}.meta.json`), { n }) || { n } - writeJSON(path.join(P.versions(), `v${n}.meta.json`), { - ...meta, n, capturedAt: new Date().toISOString(), route: body.route || '/', - }) + const before = new Set(loadComments().filter(c => c.sentAt).map(c => c.id)) + const comments = acceptFromReviewer(body.comments || []) + const sent = comments.filter(c => c.sentAt && !before.has(c.id)) + if (sent.length) console.log(`\n● ${sent.length} comment(s) sent — waiting for the next tick`) + touch() + return sendJSON(res, 200, { ok: true, comments }) + }) + } + /* Taking a comment off the list. Nothing the agent holds is refused: one + already delivered may be half done, and the agent finishes what it was + given whatever the reviewer does (rule 4). So a delivered comment keeps its + record, closed and marked dismissed — the id still resolves, so the close + the agent is about to run is the no-op rule 5 promises. One that never left + the workspace has no such reader, and goes outright. */ + if (p === '/api/comments/dismiss' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}') + return withStoreLock(() => { + const comments = loadComments() + const target = comments.find(comment => comment.id === body.id) + if (!target) return sendJSON(res, 404, { error: 'No such comment' }) + if (target.deliveredAt) { + target.dismissedAt = new Date().toISOString() + target.state = 'closed' + saveComments(comments) + } else { + saveComments(comments.filter(comment => comment.id !== body.id)) + } + touch() return sendJSON(res, 200, { ok: true }) }) } - if (p === '/api/annotations' && req.method === 'POST') { + /* Taking a reply back off a thread. The union merge (rule 7) means a save + that omits a line is a stale copy, not a removal — so removal is a request + of its own, exactly as dismissing is for a comment. Only the thread's last + line can go, and only the reviewer's own: words the agent has answered are + what the answer means, and stay. The agent is not told; whatever it already + took delivery of, it finishes from (rule 4). */ + if (p === '/api/comments/unreply' && req.method === 'POST') { const body = JSON.parse(await readBody(req) || '{}') - const n = Number(body.version) || loadState().version return withStoreLock(() => { - writeJSON(path.join(P.review(n), 'annotations.json'), { - version: n, updatedAt: new Date().toISOString(), - annotations: mergeIncoming(n, body.annotations || []), - }) + const comments = loadComments() + const target = comments.find(comment => comment.id === body.id) + if (!target) return sendJSON(res, 404, { error: 'No such comment' }) + const replies = target.replies || [] + const matches = reply => reply.by === REVIEWER_ROLE && + reply.at === body.at && reply.text === body.text + if (replies.length && matches(replies.at(-1))) { + target.replies = replies.slice(0, -1) + saveComments(comments) + touch() + return sendJSON(res, 200, { ok: true }) + } + // Still on the thread but no longer its last line: something has been + // said since, and the words underneath an answer are not takeable-back. + if (replies.some(matches)) return sendJSON(res, 409, { error: 'Already answered' }) + // Not found at all is already gone — a second click, or another tab. + return sendJSON(res, 200, { ok: true }) + }) + } + /* Give a stranded round back to the queue. + A delivered comment is not `unseen`, so a watcher armed after the session + behind it died blocks and hands over nothing: the round sits where no agent + can reach it and no tick will raise it. Clearing `deliveredAt` puts those + comments back where the state table says a sent, undelivered comment + belongs, and the next tick takes them. + Refused while a heartbeat says someone is listening, because then the round + is not stranded — an agent holds it and owes an answer on it, and handing + the same comment to a second session is the race. */ + if (p === '/api/comments/requeue' && req.method === 'POST') { + return withStoreLock(() => { + if (agentListening()) { + return sendJSON(res, 409, { + error: 'The agent is listening — it still has these. Reply on one to ask for it back.', + }) + } + const comments = loadComments() + const stranded = comments.filter(comment => comment.state === 'open' && comment.deliveredAt) + for (const comment of stranded) { comment.deliveredAt = null; comment.deliveredTo = null } + saveComments(comments) + touch() + return sendJSON(res, 200, { ok: true, requeued: stranded.map(comment => comment.id) }) + }) + } + /* Start the review over. Everything a version of this tool wrote about this + review goes — the comments, the brief, the snapshots, and the directories a + store filled by an older version keeps its comments in, which would + otherwise be adopted straight back on the next read. What the review *is* + stays: the page or app under it, and its name. */ + if (p === '/api/reset' && req.method === 'POST') { + return withStoreLock(() => { + cmdReset(true) return sendJSON(res, 200, { ok: true }) }) } @@ -1427,50 +1698,6 @@ font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif;color:#667;backgr }) }) } - if (p === '/api/feedback' && req.method === 'POST') { - const body = JSON.parse(await readBody(req) || '{}') - const n = Number(body.version) || loadState().version - const dir = P.review(n) - return withStoreLock(() => { - /* Prepare the durable review material before raising its notification. The - server's reload broadcast is debounced, so these synchronous writes are - observed as one state transition rather than a transient empty round. */ - fs.mkdirSync(dir, { recursive: true }) - writeJSON(path.join(dir, 'annotations.json'), { - version: n, updatedAt: new Date().toISOString(), - annotations: mergeIncoming(n, body.annotations || []), - }) - const round = nextRound(n, body.feedback?.comments || [], path.join(dir, 'feedback.md')) - writeJSON(path.join(dir, 'feedback.json'), { ...(body.feedback || {}), roundId: round.id }) - fs.writeFileSync(path.join(dir, 'feedback.md'), String(body.markdown || '').replaceAll('<round-id>', round.id)) - const prev = fs.existsSync(P.pending()) ? readJSON(P.pending(), {}) : null - const stillOut = prev?.roundId === round.id && prev?.sentAt && Date.now() - Date.parse(prev.sentAt) < 5 * 60e3 - writeJSON(P.pending(), { - roundId: round.id, - page: FILE || appOrigin(), app: appOrigin(), - name: pageName(), - version: n, - counts: { total: round.comments.length }, - // Live: the screens the comments were made on, so the round can be - // planned before the brief is even opened. - routes: body.feedback?.routes || [], - // Which comments went out, so a workspace opened later — or reloaded - // mid-round — can put the progress back on the right ones instead of - // showing a round that looks like it never happened. - comments: round.comments.map(comment => comment.id), - feedback: path.join(dir, 'feedback.md'), - sentAt: stillOut ? prev.sentAt : new Date().toISOString(), - }) - console.log(`\n● ${round.id} sent for v${n} — ${round.comments.length} comment(s) → ${path.join(dir, 'feedback.md')}`) - return sendJSON(res, 200, { ok: true, roundId: round.id }) - }) - } - /** - * The reviewer changed their mind while you were working. This is a request to - * stop, not a hard kill — nothing here can reach into a running turn. The agent - * notices it either through watch_stream or on its next check, and answers for - * whatever it had already done. - */ /** * "Give me a link I can send to someone." The workspace cannot publish a * public URL — only the agent via Host op `share` can — so this raises the ask @@ -1502,30 +1729,24 @@ font:14px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif;color:#667;backgr const body = JSON.parse(await readBody(req) || '{}') const n = Number(body.version) || loadState().version return withStoreLock(() => { - const openComments = unresolvedComments() + const stillOpen = openComments() const expected = Number.isInteger(Number(body.expectedOpenCount)) ? Number(body.expectedOpenCount) : Array.isArray(body.openComments) ? body.openComments.length : null - if (expected !== null && expected !== openComments.length) { + if (expected !== null && expected !== stillOpen.length) { return sendJSON(res, 409, { error: 'The open-comment count changed. Review the current list before approving.', - openComments, + openComments: stillOpen, }) } writeJSON(P.approved(), { page: FILE || appOrigin(), app: appOrigin(), name: pageName(), version: n, - openComments, + openComments: stillOpen, at: new Date().toISOString(), }) - const active = loadActiveRound() - if (active) finishActiveRound(active, 'approved', { - approvedVersion: n, - outcomes: Object.fromEntries((active.comments || []).map(comment => [comment.id, 'left_open_on_approval'])), - }) - fs.rmSync(P.pending(), { force: true }) - const left = openComments.length + const left = stillOpen.length console.log(`\n✓ Approved at v${n}${left ? ` — ${left} comment(s) left unapplied` : ''} — the review is closed`) sendJSON(res, 200, { ok: true }) // Let the response land before the socket goes away with the process. @@ -1564,15 +1785,19 @@ async function cmdServe () { // same startup transaction upgrades feedback left by a pre-ledger server. withStoreLock(() => { if (loadState().version === 0) cmdPublish(true) - migrateLegacyPending() }) - if (LIVE) { - // Whoever picks this store up later — publish, reply, a second serve — needs - // to know it is an app and which one, without being told again. + { + /* Whoever picks this store up later — publish, reply, a second serve, a + watcher covering reviews it did not start — needs to know what this + review is of, without being told again. It is what lets one watcher hand + over the comments for every review it covers, and name the commands to + answer them with. */ const state = loadState() - state.app = APP.origin state.name = pageName() - if (args.start && args.start !== true) state.start = String(args.start).startsWith('/') ? args.start : '/' + args.start + if (LIVE) { + state.app = APP.origin + if (args.start && args.start !== true) state.start = String(args.start).startsWith('/') ? args.start : '/' + args.start + } else state.file = FILE saveState(state) } // Terminal signals belong to the review that raised them. A new one starts @@ -1599,7 +1824,10 @@ async function cmdServe () { workspace starts holding new comments back instead of sending into the round. Without this the menu sits on "publishing the link…" forever and "queued" never becomes "being worked on". */ - try { fs.watch(STORE, (_e, name) => { if (name === 'state.json' || name === 'share' || name === 'pending') touch() }) } catch {} + /* The agent writes into the same store from its own process — closing a + comment, answering one, taking delivery — and the page has to hear about it + without asking. */ + try { fs.watch(STORE, (_e, name) => { if (['state.json', 'share', 'comments.json'].includes(name)) touch() }) } catch {} server.on('error', e => { if (e.code === 'EADDRINUSE') { @@ -1675,15 +1903,15 @@ async function cmdServe () { switch (args._) { case 'publish': withStoreLock(() => cmdPublish()); break - case 'claim': withStoreLock(cmdClaim); break case 'reply': withStoreLock(cmdReply); break case 'ack': withStoreLock(cmdAck); break case 'share': withStoreLock(cmdShare); break case 'status': cmdStatus(); break - case 'check': cmdCheck(); break + case 'unanswered': cmdUnanswered(); break + case 'reset': withStoreLock(cmdReset); break case 'watch': cmdWatch(); break case 'serve': cmdServe(); break default: - console.error(`Unknown command "${args._}". Use: serve | claim | publish | reply | ack | share | status | check | watch`) + console.error(`Unknown command "${args._}". Use: serve | watch | publish | reply | ack | share | status | unanswered | reset`) process.exit(1) } diff --git a/plugins/vstack/skills/review/assets/workspace.html b/plugins/vstack/skills/review/assets/workspace.html index 3b23108..f01bd0a 100644 --- a/plugins/vstack/skills/review/assets/workspace.html +++ b/plugins/vstack/skills/review/assets/workspace.html @@ -13,58 +13,67 @@ /* One palette for every vstack page. Roles, not colours: a page asks for --surface, not for white, so light and dark are the same stylesheet. + The values come from `design/tokens.css`, which owns the palette. They are + copied rather than imported because a page has to work opened off disk and + inlined into an Artifact under a CSP that blocks every external request — + nothing here may be fetched. `tests/design-tokens.mjs` fails when the two + files disagree, so the copy cannot drift quietly. + Page-specific hues (the story map's phase bands, the board's new/have/touch, the spec's priorities) stay in the page, below this block — they mean something only there. Everything here is shared, and is the reason a board and a spec look like the same product. Three states, in this order: the OS preference, then an explicit choice. - `data-theme` absent means auto. */ + `data-theme` absent means auto. + + The type scale is the guide's; the families are not. Space Grotesk and Inter + would each be an external request, so every page reads in the system stack. */ :root{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); - --radius:9px; + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); + --radius:8px; --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color-scheme:light; } @media (prefers-color-scheme:dark){:root{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; }} :root[data-theme=light]{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); color-scheme:light; } :root[data-theme=dark]{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; } /* /vstack:shell tokens */ @@ -108,6 +117,28 @@ #panel{min-width:0;overflow:hidden} #panel > *{width:var(--panelw);flex:none} #pbody{flex:1 1 auto!important} +/* How much of the window the comments take is the reviewer's call: the panel's + inner edge is a grip, and where they leave it is where it stays. Two ids to + outrank the width every other child of the panel gets. */ +#panel > #panelGrip{position:absolute;left:0;top:0;bottom:0;width:7px;z-index:44; + cursor:col-resize;background:transparent;outline:none} +/* Wide enough to grab, and what it shows is a slim line rather than the whole + target. Grey: the edge is a thing to move, not a thing to worry about. + Flush at left:0 — absolute positioning starts inside the panel's own 1px + border, so the line lands against it and the two read as one edge. */ +#panel > #panelGrip::after{content:'';position:absolute;left:0;top:0;bottom:0;width:3px; + background:transparent;transition:background .12s} +#panel > #panelGrip:hover::after,#panel > #panelGrip:focus-visible::after{background:var(--line-2)} +body.resizing #panel > #panelGrip::after{background:var(--ink-3)} +/* The panel's own border is the other pixel of the same edge, so it takes the + same colour — otherwise the line reads as a stripe beside the edge. */ +#panel:has(> #panelGrip:hover),#panel:has(> #panelGrip:focus-visible){border-left-color:var(--line-2)} +body.resizing #panel{border-left-color:var(--ink-3)} +body.panelshut #panelGrip{display:none} +/* The pointer owns the drag: no text selection under it, and no easing on a + column that is meant to track the cursor exactly. */ +body.resizing{cursor:col-resize;user-select:none} +body.resizing #main{transition:none} /* Up at the top, level with the panel's own heading — halfway down the window it read as floating, and the thing it belongs to starts here. */ #panelHandle{display:flex;grid-column:2;grid-row:1;align-self:start;justify-self:start; @@ -123,12 +154,11 @@ body.phase #panelHandle{display:none!important} #panelHandle:hover{color:var(--ink);background:var(--surface-2)} #panelHandle svg{width:13px;height:13px;display:block;transition:transform .16s ease-out} -/* Shut, the handle is the only way back to the comments, so it wears the brand - — at this size a neutral chevron on the edge went unnoticed. */ -body.panelshut #panelHandle{color:var(--brand); - border-color:var(--brand-line);background:var(--brand-soft)} -body.panelshut #panelHandle:hover{background:var(--brand);border-color:var(--brand);color:#fff} -body.panelshut #panelHandle:hover .pbadge{background:#fff;color:var(--brand)} +/* Shut, the handle stays part of the chrome rather than wearing the brand. Its + badge is what says comments are waiting, so the colour is not carrying that. */ +body.panelshut #panelHandle{color:var(--ink); + border-color:var(--line-2);background:var(--surface)} +body.panelshut #panelHandle:hover{background:var(--surface-2);border-color:var(--line-2);color:var(--ink)} /* The handle is narrower than a default badge, so its own count runs tighter. */ #handleBadge{min-width:14px;height:14px;padding:0 2px;font-size:9px} /* Keep comments beside the canvas for every width that still uses the desktop @@ -148,6 +178,9 @@ body.panelopen #panel{transform:none} /* Cards fill the panel at whatever width it ended up. */ #panel > *{width:100%} + /* The panel is a drawer over the page here, not a column beside it, so there + is no line between the two to move. */ + #panel > #panelGrip{display:none} /* Under the chrome like the panel it belongs to — pinned to a bar's height it would sit on top of an update notice and cover the × that dismisses it. */ #panelHandle{grid-column:1;position:fixed;right:0;top:calc(var(--chromeh,46px) + 18px); @@ -316,8 +349,10 @@ .banner.good .tick{color:var(--ok);font-weight:700} /* the toast — VSShell.toast(); one element, appended on first use */ -.vs-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; - background:var(--ink);color:var(--surface);padding:8px 14px;border-radius:8px; +/* It is a popover, so the browser's own [popover] rules apply first: inset, + margin and border are theirs to set and ours to put back. */ +.vs-toast{position:fixed;inset:auto;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; + background:var(--ink);color:var(--surface);border:0;margin:0;padding:8px 14px;border-radius:8px; font-size:12.5px;box-shadow:var(--shadow-pop);opacity:0;pointer-events:none;transition:opacity .25s} .vs-toast.on{opacity:1} @@ -608,12 +643,12 @@ #composer .cthread{margin:8px 9px 0;display:none;flex-direction:column;gap:7px; max-height:190px;overflow:auto} #composer .cthread.on{display:flex} -#composer .msg{border-left:2px solid var(--line-2);padding-left:8px} +#composer .msg{border-left:2px solid var(--line-2);padding-left:8px;font-size:12.5px} #composer .msg.agent,#composer .msg.claude{border-left-color:var(--brand)} #composer .msg .who{font:600 10px/1 var(--font);letter-spacing:.04em;text-transform:uppercase; color:var(--ink-3);margin-bottom:3px} #composer .msg.agent .who,#composer .msg.claude .who{color:var(--brand)} -#composer .msg .body{font-size:12.5px;line-height:1.45;white-space:pre-wrap;word-break:break-word} +#composer .msg .body{line-height:1.45;white-space:pre-wrap;word-break:break-word} #composer .creply{display:none;margin-top:8px;min-height:44px} #composer .creply.on{display:block} #composer .cfoot{display:flex;align-items:center;gap:5px;padding:8px 9px;margin-top:8px; @@ -662,6 +697,14 @@ #pwork .spin{width:12px;height:12px;border-radius:50%;border:2px solid var(--brand-line); border-top-color:var(--brand);animation:spin .7s linear infinite;flex:none} #pwork .ptxt{flex:1;min-width:0;font-size:11.5px;color:var(--ink-2);line-height:1.35} +/* Stalled is the absence of progress, so the strip stops claiming any: the + spinner goes, and the brand tint that says "in hand" goes with it. */ +#pwork.stalled{background:var(--surface-2)} +#pwork.stalled .spin{display:none} +#pwork .again{font-size:11.5px;color:var(--ink-3);text-decoration:underline;white-space:nowrap;flex:none} +#pwork .again:hover:not(:disabled){color:var(--brand)} +#pwork .again:disabled{opacity:.35;cursor:default;text-decoration:none} +#pwork .again[hidden]{display:none} @keyframes spin{to{transform:rotate(360deg)}} @keyframes sweep{0%{left:-38%}55%{left:100%}100%{left:100%}} #pfoot{border-top:1px solid var(--line);padding:9px 10px;display:flex;gap:7px;align-items:center} @@ -691,6 +734,14 @@ padding:5px 7px;resize:vertical;min-height:46px} .item .gnote:focus{outline:none;border-color:var(--brand); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand) 12%,transparent)} +/* The same footer the on-canvas composer carries, so a comment typed in the + list is finished the same way as one typed on the page. */ +.item .gfoot{display:flex;align-items:center;gap:5px;margin-top:6px} +.item .gfoot .hint{flex:1;font-size:11px;color:var(--ink-3)} +.item .gsave{height:24px;padding:0 10px;border-radius:6px;background:var(--ink); + color:var(--surface);font-weight:600;font-size:11.5px;flex:none} +.item .gsave:hover{filter:brightness(1.25)} +.item .gsave .kbd{font:500 9.5px/1 var(--mono);opacity:.6;margin-left:4px} .item.off{opacity:.68} .item.off:hover{opacity:1} .item .kind .offtag{color:var(--ink-3);font-style:italic} @@ -788,6 +839,19 @@ color:var(--ink-3);font-size:12px;line-height:1;display:grid;place-items:center;opacity:0} .item .tmsg:hover .tkill,.item .tkill:focus-visible{opacity:1} .item .tkill:hover{color:var(--brand);background:var(--brand-soft)} +/* Answers to pick from. Full width and stacked: they are sentences, not chips, + and a row of them would wrap into something you have to read twice. */ +.choices{display:flex;flex-direction:column;gap:5px;margin-top:7px} +.choice{display:flex;flex-direction:column;align-items:flex-start;gap:3px;width:100%;text-align:left; + padding:7px 9px;border:1px solid var(--line-2);border-radius:7px;background:var(--surface); + color:var(--ink);font:inherit;line-height:1.4} +.choice:hover:not(:disabled){border-color:var(--brand);background:var(--brand-soft)} +.choice:disabled{opacity:.55} +/* The one the agent would take is labelled above the answer, in the same + eyebrow a group heading uses. Its border stays a shade off full brand, so + hovering it still reads as a change. */ +.choice.rec{border-color:var(--brand-line)} +.choice em{font:650 9.5px/1 var(--font);letter-spacing:.06em;text-transform:uppercase;color:var(--brand)} .empty{color:var(--ink-3);font-size:12.5px;text-align:center;line-height:1.6} .grouphd{font:650 10.5px/1 var(--font);letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3); margin:12px 2px 7px;display:flex;align-items:center;gap:6px} @@ -846,6 +910,35 @@ .banner.on ~ .banner.on ~ .banner.on{top:150px} .banner .btn{height:25px;font-size:11.5px} #workBanner .txt,#shareBanner .txt{min-width:0;overflow:hidden;text-overflow:ellipsis} +/* Two things stacked, not one green box: the line that announces the round is + the snackbar and keeps the green; the account under it is a page of the + agent's writing, and reads as one. So the container itself carries nothing. */ +#workBanner.on{flex-direction:column;align-items:stretch;gap:6px; + background:none;border:0;padding:0;box-shadow:none; + /* One width whether the account is open or folded: the banner is in a fixed + place on screen, and a box that resizes under the pointer is a box you + have to aim at twice. */ + width:min(560px,calc(100% - 24px))} +#workBanner .brow{display:flex;align-items:center;gap:10px; + padding:8px 10px 8px 14px;border-radius:10px; + background:var(--ok-soft);border:1px solid var(--ok);box-shadow:var(--shadow-pop)} +/* The headline takes the slack, so every button sits at the right edge — level + with the edge of the account below it. */ +#workBanner .brow .txt{flex:1} +#workBanner .summary{padding:10px 12px;border-radius:10px;background:var(--surface); + border:1px solid var(--line);box-shadow:var(--shadow-pop);max-height:40vh;overflow:auto} +/* The words are the agent's own, newlines and all, and they are the reason the + banner stops being one line. */ +#workBanner .summary .stext{white-space:pre-wrap;font-size:12px;line-height:1.5;color:var(--ink-2)} +/* The one control the account needs: the chevron every accordion uses. It + points the way the account will move — up while it is open and about to + fold, down while it is folded and about to come back. Bare, because the two + things you might do about the round are the buttons beside it. */ +#workBanner .chev{flex:none;width:26px;height:26px;display:grid;place-items:center; + border-radius:6px;background:none;border:0;color:var(--ink-2);cursor:pointer} +#workBanner .chev:hover{color:var(--ink);background:color-mix(in srgb,var(--ok) 16%,transparent)} +#workBanner .chev svg{width:16px;height:16px;display:block;transition:transform .16s ease-out} +#workBanner .chev[aria-expanded=true] svg{transform:rotate(180deg)} /* The tools, down the left edge of the canvas. Same shell as the zoom controls in the corner below it — both are things you reach for while working on the @@ -894,10 +987,16 @@ .confirmDialog .modalbody{padding:20px 20px 16px} .confirmDialog h2{margin:0 0 9px;font-size:17px;line-height:1.25} .confirmDialog p{margin:0;color:var(--ink-2);font-size:13px;line-height:1.55} +.confirmDialog .opt{display:flex;align-items:center;gap:8px;margin-top:12px; + font-size:13px;color:var(--ink-2);cursor:pointer} +.confirmDialog .opt input{flex:none;width:14px;height:14px;margin:0} .confirmDialog .kept{margin-top:10px;padding:9px 10px;border-radius:7px;background:var(--surface-2); border:1px solid var(--line);color:var(--ink-2)} .confirmDialog .modalactions{display:flex;justify-content:flex-end;gap:8px;padding:12px 20px; border-top:1px solid var(--line);background:var(--surface-2)} +/* Ours, in the slot the shell offers inside its settings menu. */ +.cogmenu .row.reset{display:block} +.cogmenu .row.reset .btn{width:100%;justify-content:center} .confirmDialog .danger{background:var(--brand);border-color:var(--brand);color:#fff} .confirmDialog .danger:hover{filter:brightness(1.08)} .confirmDialog .danger:disabled{opacity:.45;filter:none} @@ -996,11 +1095,19 @@ <div class="row about" id="cogAbout" hidden> <span class="lbl">Version</span> <span class="abouts"> - <span class="one"><em>workspace</em><b id="cogVersionPage">—</b></span> + <span class="one"><em>workspace UI</em><b id="cogVersionPage">—</b></span> <span class="one" id="cogServerLine"><em>server</em><b id="cogVersionServer">—</b></span> </span> </div> <div class="row stale" id="cogStale" hidden>Reload to pick up the server's version.</div> + <!-- What only this tool can offer about its own state — starting it over, + usually. Every tool keeps different things, so the shell offers the + place rather than the content. --> + <!-- vstack:slot cog --> + <div class="row reset"> + <button class="btn sm" id="btnReset"></button> + </div> + <!-- /vstack:slot cog --> </div> </div> <div class="sep"></div> @@ -1013,8 +1120,6 @@ aria-label="More">▾</button> </div> <div id="sendMenu"> - <button id="btnSendNow"><span class="t"></span><span class="d"></span></button> - <div class="mrule" id="sendNowRule"></div> <button id="btnShare"><span class="t"></span><span class="d"></span></button> <div class="mrule" id="shareRule"></div> <button id="btnApprove"><span class="t"></span><span class="d"></span></button> @@ -1086,9 +1191,23 @@ <!-- Only ever announces a version that has landed. Work in flight is shown on the comments it belongs to, in the panel. --> <div class="banner good" id="workBanner"> - <span class="txt" id="workText"></span> - <button class="btn sm primary" id="btnRefresh">Review changes</button> - <button class="btn sm" id="btnDismissWork">Later</button> + <div class="brow"> + <span class="txt" id="workText"></span> + <button class="btn sm primary" id="btnRefresh">Review changes</button> + <button class="btn sm" id="btnDismissWork">Later</button> + <button class="chev" id="btnSummary" aria-controls="workSummary" hidden> + <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" + stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> + <path d="M4 6.5 8 10.5l4-4"/> + </svg> + </button> + </div> + <!-- What the agent would have told them in the terminal. The chevron + above opens and closes it, and the next round opens the way this + one was left. --> + <div class="summary" id="workSummary" hidden> + <div class="stext" id="workSummaryText"></div> + </div> </div> <!-- The link is asked for here and lands minutes later, long after the reviewer stopped watching the ▾. Say so where the news goes. --> @@ -1143,11 +1262,13 @@ <span class="pbadge" id="handleBadge"></span> </button> <div id="panel"> + <div id="panelGrip" role="separator" aria-orientation="vertical" tabindex="0"></div> <div id="phead"><b>Comments</b><span class="scope pill" id="pscope"></span> <button id="btnGeneral" aria-label="Add a comment">+</button></div> <div id="pwork" hidden> <span class="spin"></span> <span class="ptxt" id="pworkText"></span> + <button class="again" id="btnAgain" hidden></button> </div> <div id="pbody"></div> <div id="pfoot"> @@ -1158,10 +1279,25 @@ </div> </div> +<dialog class="confirmDialog" id="resetDialog" aria-labelledby="resetDialogTitle" aria-describedby="resetDialogImpact resetDialogKept"> + <div class="modalbody"> + <h2 id="resetDialogTitle"></h2> + <p id="resetDialogImpact"></p> + <p class="kept" id="resetDialogKept"></p> + </div> + <div class="modalactions"> + <button class="btn" id="btnCancelReset"></button> + <button class="btn danger" id="btnConfirmReset"></button> + </div> +</dialog> <dialog class="confirmDialog" id="clearDialog" aria-labelledby="clearDialogTitle" aria-describedby="clearDialogImpact clearDialogKept"> <div class="modalbody"> <h2 id="clearDialogTitle"></h2> <p id="clearDialogImpact"></p> + <!-- Off by default: an open comment is work the reviewer still wants, and + tidying the list is not a reason to lose it. --> + <label class="opt"><input type="checkbox" id="clearOpenToo"> + <span id="clearOpenTooLabel"></span></label> <p class="kept" id="clearDialogKept"></p> </div> <div class="modalactions"> @@ -1356,14 +1492,34 @@ <h2 id="doneTitle"></h2> } /* ── a toast: the page saying "done" without stopping anyone ── */ + /* Long enough for the opacity transition in shell.css to finish before the + toast leaves the top layer, so it fades rather than vanishing. */ + const TOAST_FADE_MS = 300; let toastTimer = null; function toast (msg, ms = 2200) { let el = document.querySelector('.vs-toast'); - if (!el) { el = document.createElement('div'); el.className = 'vs-toast'; document.body.appendChild(el) } + if (!el) { + el = document.createElement('div'); + el.className = 'vs-toast'; + /* A modal dialog paints in the top layer, above every z-index there is, + and its backdrop blurs what lies under it. A toast raised while one is + open has to join the top layer or it is unreadable behind the very + dialog whose failure it is reporting. */ + el.popover = 'manual'; + document.body.appendChild(el); + } el.textContent = msg; + /* Promoted on each toast rather than left open, because the top layer + 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 {} el.classList.add('on'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('on'), ms); + toastTimer = setTimeout(() => { + el.classList.remove('on'); + toastTimer = setTimeout(() => { try { el.hidePopover() } catch {} }, TOAST_FADE_MS); + }, ms); } /* ── two-step confirm on one button ── @@ -1471,6 +1627,9 @@ <h2 id="doneTitle"></h2> const btn = $('#settingsBtn'), menu = $('#settingsMenu'); if (!btn || !menu) return; const open = on => { menu.hidden = !on; btn.setAttribute('aria-expanded', String(on)) }; + // A control in the cog's slot can act on the page behind it, so the page + // needs a way to put the menu away first. + closeSettings = () => open(false); btn.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden) }); menu.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => open(false)); @@ -1504,9 +1663,12 @@ <h2 id="doneTitle"></h2> return api; } + let closeSettings = () => {}; + const api = { init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, + closeSettings: () => closeSettings(), get theme () { return theme }, get lang () { return lang }, onLang (fn) { langListeners.push(fn) }, @@ -1651,7 +1813,7 @@ <h2 id="doneTitle"></h2> appears — mark, list card, composer. Each of those sets `--mk` once and every part it is made of picks the colour up from there. */ const MARK_DONE = 'var(--ok)'; -const markColour = a => a.status === 'addressed' ? MARK_DONE : MARK; +const markColour = a => isClosed(a) ? MARK_DONE : MARK; /* ── language ───────────────────────────────────────────────────────── The workspace chrome speaks two languages; the page under review is @@ -1671,11 +1833,27 @@ <h2 id="doneTitle"></h2> del: 'Delete comment', clearAll: 'Clear all', cleared: n => `Cleared ${n} comment${n > 1 ? 's' : ''}`, clearDialogTitle: 'Clear all comments?', - clearImpact: n => `This removes ${n} comment${n === 1 ? '' : 's'} from the review, including any carried from earlier versions.`, + clearImpact: 'Addressed comments come off the list. Anything still open stays where it is unless you tick the box below. {agent} finishes whatever it is working on right now.', + clearOpenToo: 'Clear the comments that are still open too', + recommended: 'Recommended', + clearFail: n => `The server would not take ${n} of them off — those are still on the review.`, clearKept: 'The wireframe and version history will stay.', clearCancel: 'Cancel', clearConfirm: 'Clear all', clearHistory: 'Clear history', clearHistoryTitle: 'Delete past versions', + reset: 'Hard reset Visual Stack', + resetTitle: 'Delete every comment and version, and start this review at v1', + resetWorking: 'Resetting…', + resetDialogTitle: 'Hard reset this review?', + resetImpact: (c, v) => `This deletes ${c} comment${c === 1 ? '' : 's'} and ${v} version${v === 1 ? '' : 's'}, including everything {agent} has already answered. It cannot be undone.`, + resetImpactLive: c => `This deletes ${c} comment${c === 1 ? '' : 's'}, including everything {agent} has already answered. It cannot be undone.`, + resetKept: 'Nothing {agent} changed is undone. The page keeps every edit it has made, and becomes v1 again.', + resetKeptLive: 'Nothing {agent} changed is undone. The app stays exactly as it is — only the review starts again.', + resetCancel: 'Keep it', + resetConfirm: 'Hard reset', + resetDone: 'The review starts again at v1', + resetFail: 'Could not reset — is the review server still running?', + resetOld: 'This review server started before Hard reset existed. Restart it, or run `review-server.mjs reset` yourself.', clearHistoryDialogTitle: 'Clear version history?', clearHistoryImpact: n => `This permanently deletes ${n} past version${n === 1 ? '' : 's'}. You will no longer be able to scrub back to ${n === 1 ? 'it' : 'them'}.`, clearHistoryKept: 'The current version and all comments will stay.', @@ -1692,6 +1870,7 @@ <h2 id="doneTitle"></h2> comment: 'Comment', areaComment: 'Area comment', generalComment: 'On the page', moveComment: 'Move', strikeComment: 'Delete', strikeText: 'Delete text', addGeneral: 'Add a comment about the page as a whole', notePlaceholder: 'What needs to change?', + panelWidth: 'Drag to set how wide the comments are', /* Move and Delete already say what they want. Anything typed on one adds to it, so the box asks for that rather than for the whole instruction. */ extraPlaceholder: 'Anything to add? (optional)', @@ -1717,8 +1896,9 @@ <h2 id="doneTitle"></h2> working: n => `Sent ${n} comment${n > 1 ? 's' : ''} — {agent} is working…`, hiNew: 'Highlight new', hiNewTitle: 'Outline what this phase adds over the one before it', phaseCur: 'the full design', phaseOf: n => `Phase ${n}`, - editing: '{agent} is editing the page…', ready: v => `<b>v${v} is ready</b>`, changed: '<b>The page changed</b> — reload to see it', - reviewChanges: 'Review changes', later: 'Later', backToCurrent: 'Back to current', + editing: '{agent} is editing the page…', roundDone: '<b>{agent} is done</b>', changed: '<b>The page changed</b> — reload to see it', + summaryToggle: 'Show or hide what changed', + reviewChanges: 'Refresh', later: 'Later', backToCurrent: 'Back to current', viewingOld: v => `Viewing v${v} — read only`, readOnly: v => `read-only · v${v}`, reloadTitle: 'Reload the page', current: 'current', replied: '{agent} replied', nowReviewing: v => `Now reviewing v${v}`, historyBlocked: 'Viewing an earlier version — return to current to comment', @@ -1730,10 +1910,11 @@ <h2 id="doneTitle"></h2> queuedOn: n => `${n} comment${n > 1 ? 's' : ''} waiting to be picked up`, notPickedUp: n => `${n} comment${n > 1 ? 's' : ''} sent — not picked up yet`, workingAndQueued: (w, q) => `{agent} is working on ${w} · ${q} waiting`, + stalled: n => `{agent} has stalled — ${n} comment${n > 1 ? 's' : ''} still with it`, + sendAgain: 'Send again', + sentAgain: 'Back in the queue — the next session to pick up gets them.', + sendAgainRefused: '{agent} is listening again, so it still has them.', queuedTag: 'queued', - heldOn: n => `${n} comment${n > 1 ? 's' : ''} queued — sends when this round ends`, - sendNow: 'Send the queue now', - sendNowDesc: n => `${n} queued comment${n > 1 ? 's' : ''} — don't wait for this round to finish`, addressedGroup: n => `Addressed (${n})`, earlierGroup: n => `Earlier (${n})`, revert: 'Revert', refine: 'Refine', revertTitle: 'Ask {agent} to put this back the way it was', @@ -1743,12 +1924,13 @@ <h2 id="doneTitle"></h2> dismiss: 'Dismiss', dismissTitle: 'Take this off the list', more: 'More', sendOne: 'Send just this one to {agent}', - landedTag: v => 'refresh to see', - landedTitle: '{agent} changed this. Bring the new version in to see it.', - dismissed: 'Dismissed', + dismissed: 'Taken off the list', + dismissedWorking: 'Taken off your list — {agent} is already working on it.', + dismissFail: 'The server would not take that one off — it is still on the review.', + closedSome: n => `{agent} closed ${n} comment${n > 1 ? 's' : ''}`, undoReply: 'Take back this reply', + undoReplyFail: 'That reply could not be taken back — it is still on the thread.', revertNote: 'Revert this change — put it back the way it was before.', - wantsRevert: 'Revert requested', approve: 'Approve & finish', approveSure: 'Approve & close the review?', approvedTitle: 'Approved — the review is closed', approvedBody: '{agent} has been told the design is signed off and can carry on. You can close this tab.', @@ -1764,7 +1946,6 @@ <h2 id="doneTitle"></h2> sendReply: 'Reply', addReply: 'Reply', coversN: n => `${n} element${n > 1 ? 's' : ''} inside`, offscreen: 'not on screen', offscreenToast: 'Not on the page right now', - fromEarlier: cap => `from ${cap}`, anchorGone: 'not on screen', anchorLoose: 'moved since this version', inRegion: (k, l) => k === 'region' ? (l ? `in “${l}”` : '') : (l ? `in ${k} “${l}”` : `in ${k}`), /* live review */ @@ -1773,7 +1954,6 @@ <h2 id="doneTitle"></h2> offsiteToast: 'That link left the site. Comments only work inside it — press ‹ to go back.', routeBarTitle: 'Type a path and press Enter to go there', goingTo: r => `Going to ${r}`, - roundReady: n => `<b>Round ${n} is done</b>`, appChanged: '<b>The app changed</b> — reload to see it', nowRound: n => `Now on round ${n}`, noCapture: 'Nothing was sent for review from this round, so there is no capture of it', @@ -1790,11 +1970,27 @@ <h2 id="doneTitle"></h2> del: '删除批注', clearAll: '全部清除', cleared: n => `已清除 ${n} 条批注`, clearDialogTitle: '清除所有批注?', - clearImpact: n => `这会从评审中移除 ${n} 条批注,包括从过往版本延续的批注。`, + clearImpact: '已处理的批注会从列表中移除。未处理的会保留,除非勾选下方选项。{agent} 仍会完成正在处理的内容。', + clearOpenToo: '同时清除仍未处理的批注', + recommended: '建议', + clearFail: n => `服务器未能移除其中 ${n} 条 — 它们仍在本次评审中。`, clearKept: '线框图和版本历史都会保留。', clearCancel: '取消', clearConfirm: '全部清除', clearHistory: '清除历史', clearHistoryTitle: '删除过往版本', + reset: '硬重置 Visual Stack', + resetTitle: '删除所有批注与版本,本次评审从 v1 重新开始', + resetWorking: '正在重置…', + resetDialogTitle: '硬重置本次评审?', + resetImpact: (c, v) => `这会删除 ${c} 条批注和 ${v} 个版本,包括 {agent} 已处理的内容,且无法撤销。`, + resetImpactLive: c => `这会删除 ${c} 条批注,包括 {agent} 已处理的内容,且无法撤销。`, + resetKept: '{agent} 所做的改动不会被撤销。页面保留全部改动,并重新成为 v1。', + resetKeptLive: '{agent} 所做的改动不会被撤销。应用保持不变 — 只有评审重新开始。', + resetCancel: '保留', + resetConfirm: '硬重置', + resetDone: '评审已从 v1 重新开始', + resetFail: '重置失败 — 评审服务器还在运行吗?', + resetOld: '这个评审服务器启动于「硬重置」加入之前。请重启它,或自行运行 `review-server.mjs reset`。', clearHistoryDialogTitle: '清除版本历史?', clearHistoryImpact: n => `这会永久删除 ${n} 个过往版本,之后将无法再切换回这些版本。`, clearHistoryKept: '当前版本和所有批注都会保留。', @@ -1808,6 +2004,7 @@ <h2 id="doneTitle"></h2> comment: '批注', areaComment: '区域批注', generalComment: '关于整个页面', moveComment: '移动', strikeComment: '删除', strikeText: '删除文字', addGeneral: '添加一条关于整个页面的批注', notePlaceholder: '需要改什么?', + panelWidth: '拖动以调整批注栏宽度', extraPlaceholder: '还有什么要补充的?(可选)', moveNote: '移动这个', strikeNote: '删除这个', dropInside: '→ 移入', dropBefore: '→ 移到之前', dropAfter: '→ 移到之后', @@ -1831,8 +2028,9 @@ <h2 id="doneTitle"></h2> working: n => `已发送 ${n} 条批注 — {agent} 处理中…`, hiNew: '高亮新增', hiNewTitle: '标出本阶段相对上一阶段新增的部分', phaseCur: '完整设计', phaseOf: n => `阶段 ${n}`, - editing: '{agent} 正在修改页面…', ready: v => `<b>v${v} 已就绪</b>`, changed: '<b>页面已更新</b> — 重新加载查看', - reviewChanges: '查看更新', later: '稍后', backToCurrent: '回到最新版本', + editing: '{agent} 正在修改页面…', roundDone: '<b>{agent} 已完成</b>', changed: '<b>页面已更新</b> — 重新加载查看', + summaryToggle: '展开或收起变更说明', + reviewChanges: '刷新', later: '稍后', backToCurrent: '回到最新版本', viewingOld: v => `正在查看 v${v} — 只读`, readOnly: v => `只读 · v${v}`, reloadTitle: '重新加载页面', current: '当前', replied: '{agent} 回复了', nowReviewing: v => `正在评审 v${v}`, historyBlocked: '正在查看历史版本 — 回到最新版本才能批注', @@ -1844,10 +2042,11 @@ <h2 id="doneTitle"></h2> queuedOn: n => `${n} 条批注等待接收`, notPickedUp: n => `${n} 条批注已发送,尚未被接收`, workingAndQueued: (w, q) => `{agent} 正在处理 ${w} 条 · ${q} 条等待中`, + stalled: n => `{agent} 已停止响应 — ${n} 条批注仍在它那里`, + sendAgain: '重新发送', + sentAgain: '已重新排队 — 下一个接手的会话会收到。', + sendAgainRefused: '{agent} 已重新连接,这些仍在它那里。', queuedTag: '等待中', - heldOn: n => `${n} 条已排队 — 本轮结束后发送`, - sendNow: '立即发送队列', - sendNowDesc: n => `${n} 条排队中 — 不等本轮结束`, addressedGroup: n => `已处理(${n})`, earlierGroup: n => `更早(${n})`, revert: '撤回', refine: '继续完善', revertTitle: '让 {agent} 把这处改回原样', @@ -1857,12 +2056,13 @@ <h2 id="doneTitle"></h2> dismiss: '不用管了', dismissTitle: '从列表中移除', more: '更多', sendOne: '只发送这一条给 {agent}', - landedTag: v => '刷新查看', - landedTitle: '{agent} 改过这处。加载新版本即可看到。', - dismissed: '已移除', + dismissed: '已从列表中移除', + dismissedWorking: '已从你的列表中移除 — {agent} 仍在处理这条。', + dismissFail: '服务器未能移除这条 — 它仍在本次评审中。', + closedSome: n => `{agent} 关闭了 ${n} 条批注`, undoReply: '撤回这条回复', + undoReplyFail: '这条回复未能撤回 — 它仍在会话中。', revertNote: '撤销这处改动——恢复成修改之前的样子。', - wantsRevert: '要求撤回', approve: '批准并结束', approveSure: '确定批准并关闭评审?', approvedTitle: '已批准 — 评审已关闭', approvedBody: '{agent} 已收到定稿通知,可以继续下一步。你可以关闭此标签页。', @@ -1878,7 +2078,6 @@ <h2 id="doneTitle"></h2> sendReply: '发送', addReply: '回复', coversN: n => `框内 ${n} 个元素`, offscreen: '当前不可见', offscreenToast: '页面上当前看不到这个元素', - fromEarlier: cap => `来自 ${cap}`, anchorGone: '当前不可见', anchorLoose: '页面已改动,位置为大致位置', inRegion: (k, l) => k === 'region' ? (l ? `位于「${l}」` : '') : (l ? `位于 ${k}「${l}」` : `位于 ${k}`), /* 实时评审 */ @@ -1887,7 +2086,6 @@ <h2 id="doneTitle"></h2> offsiteToast: '该链接已离开站点,批注只在站内有效 — 按 ‹ 返回。', routeBarTitle: '输入路径后按回车跳转', goingTo: r => `正在前往 ${r}`, - roundReady: n => `<b>第 ${n} 轮已完成</b>`, appChanged: '<b>应用已更新</b> — 重新加载查看', nowRound: n => `当前为第 ${n} 轮`, noCapture: '这一轮没有发送过评审,因此没有留下快照', @@ -1927,9 +2125,9 @@ <h2 id="doneTitle"></h2> const S = { runtime: 'local', name: 'Review', fileName: 'page.html', - html: '', versions: [], reviews: {}, + html: '', versions: [], comments: [], version: 1, viewing: 1, - historyClearedAt: null, clearingHistory: false, + historyClearedAt: null, clearingHistory: false, resetting: false, /* `mode` is what the pointer does right now — View, or one of the annotate tools. `tool` remembers which tool that was, so leaving for View and coming back returns the tool rather than resetting to Comment. */ @@ -1938,25 +2136,18 @@ <h2 id="doneTitle"></h2> // `fitted` says the zoom is the one that fills the canvas rather than one the // reviewer chose, so it is free to follow the canvas when the canvas changes. zoom: 1, fitted: true, - ann: [], carried: [], + ann: [], sel: null, // Comments whose element is not on the page as it stands right now. offscreen: new Set(), // Threads opened in the panel, and which reply box the cursor is in. threadOpen: new Set(), focusReply: null, - awaiting: false, pendingUpdate: null, sentSig: null, - activeRoundId: null, - queued: new Set(), // sent, but the brief has not been collected yet - collected: new Set(), // ids Claude has taken delivery of - landed: new Set(), // dealt with by a version this page has not adopted yet - landedVersion: null, - // Which comments went out with the last send and have not come back yet. - working: new Set(), - /* Sent while a round was already collected and underway. Nothing goes on the - wire: the ids wait here — and as a `held` flag on the comment itself, which - is what survives a reload — then go out as one batch when the round ends. */ - held: new Set(), - // A stop has been asked for and the round has not ended yet. + editingNote: null, // the general comment whose box is open + // Deleted in this tab — never to be folded back in by a refresh. + forgotten: new Set(), + // Replies taken back in this tab, by thread key, for the same reason. + retracted: new Set(), + pendingUpdate: null, linked: null, // null until the connection has actually said one way or other // The shareable Artifact: asked for here, published by Claude, link comes back. share: { url: null, version: null, pending: false }, @@ -1990,40 +2181,27 @@ <h2 id="doneTitle"></h2> S.fileName = data.fileName || 'page.html'; S.html = data.html || ''; S.versions = data.versions || []; - S.reviews = data.reviews || {}; + S.comments = data.comments || []; S.version = S.viewing = data.currentVersion || 1; + /* Whatever the agent last said is already said by the time the page opens — + the banner is for a round landing while the reviewer watches, not for one + they missed. So the first payload sets the mark rather than announcing. */ + S.summaryAt = data.summary?.at || null; S.historyClearedAt = data.historyClearedAt || null; S.app = data.app || null; S.startPath = data.startPath || '/'; if (live()) S.route = S.startPath; adoptShare(data); VSShell.setWatching(data.watching); - S.watching = data.watching; + noteWatching(data.watching); VSShell.setServerVersion(data.version); const phase = S.runtime === 'phase'; + /* Work in flight belongs to the review, not to the tab that started it. Each + comment carries whether it has been sent and whether Claude has been handed + it, so a reload — or a second tab — picks the progress back up instead of + showing a workspace where nothing ever happened. */ loadAnnotations(); - /* A round in flight belongs to the review, not to the tab that started it. - The brief on disk names the comments that went out, so a reload — or a - second tab — picks the progress back up instead of showing a workspace - where nothing ever happened. */ - S.activeRoundId = data.activeReview?.id || null; - const out = data.activeReview?.comments || data.pendingReview?.comments; - if (out?.length) { - S.awaiting = true; - const sentAt = data.pendingReview?.sentAt || data.activeReview?.createdAt; - const at = sentAt || new Date().toISOString(); - for (const id of out) { const a = annById(id); if (a && !a.sentAt) a.sentAt = at } - setWorking(out); - } - readFlight(data); - /* Holds outlive the tab that made them: the flag rides the saved annotation. - A round still out keeps them held; a review at rest gets them now — the - send the closing tab still owed. */ - S.held = new Set(S.ann - .filter(a => a.held && a.status === 'open' && !a.dismissed && hasSubstance(a)) - .map(a => a.id)); - if (S.held.size && !S.working.size && served()) flushHeld(); // Review is the whole product for now, so the word beside the mark says the // product rather than which of one tool you are in. Which page you are on is // already on the window below, twice. Phase preview is a different tool and @@ -2040,42 +2218,33 @@ <h2 id="doneTitle"></h2> if (served()) liveReload(); } -/** Claude spoke last and nobody has answered — the one state that stalls a round. */ -const awaitsReply = a => a.status === 'question' || - (a.status !== 'addressed' && isAgent((a.replies || []).at(-1)?.by)); +/* Everything the panel says about a comment is read off the comment. A comment + is open or closed; two timestamps say where it has got to between the + reviewer and Claude. Nothing here is remembered separately, so a reload and a + second tab see the same review as the tab that wrote it. */ +const isClosed = a => a.state === 'closed'; +const isOpen = a => !isClosed(a); +/** Let go of by the reviewer: it is Claude's to answer, and its words are set. */ +const sent = a => !!a.sentAt; +/** Written and released, but Claude has not been handed it yet. */ +const isQueued = a => !!a.sentAt && !a.deliveredAt; +/** Claude spoke last and nobody has answered — the one state that waits on you. */ +const awaitsReply = a => isOpen(a) && isAgent((a.replies || []).at(-1)?.by); +/** In Claude's hands: it went out with a delivery and has not come back closed. + A question is the exception — the comment is open and delivered, but the move + is the reviewer's, so progress on it would say the opposite of what is true. */ +const isWorking = a => !!a.deliveredAt && isOpen(a) && !awaitsReply(a); function loadAnnotations () { const local = S.runtime === 'artifact' ? lsGet() : null; - S.ann = (local ?? S.reviews[S.version]?.annotations ?? []).map(normalise); - /* What earlier versions still have to say. Open comments ride along because - they are unfinished, addressed ones because "Claude changed it" is not the - same as "that was the change I wanted", and questions because they are the - one thing waiting on the reviewer — leaving those behind took Claude's - question off the screen at the next version and left nobody to answer it. - A comment leaves the screen when the reviewer dismisses it, and not before. - A later copy overwrites an earlier one, so walk the versions in order. */ - const ghosts = new Map(); - const earlier = Object.entries(S.reviews) - .map(([v, rev]) => [Number(v), rev]) - .filter(([v]) => v < S.version) - .sort(([a], [b]) => a - b); - for (const [v, rev] of earlier) { - for (const a of rev.annotations || []) { - if (!['open', 'addressed', 'question'].includes(a.status)) continue; - ghosts.set(a.id, { ...normalise(a), fromVersion: v }); - } - } - for (const a of S.ann) ghosts.delete(a.id); // this version's copy is the live one - S.carried = [...ghosts.values()].filter(a => !a.dismissed); - // Anything Claude has said back is waiting on you, whether or not it was - // filed as a question — open the thread so the words are visible and the box - // to answer in is already there. A question asked several versions ago is - // still a question, so the carried ones count too. - for (const a of S.ann.concat(S.carried)) if (awaitsReply(a)) S.threadOpen.add(a.id); -} -// status: open (waiting on Claude) · question (Claude asked, waiting on you) · -// addressed (Claude changed it). Only Claude moves a comment to addressed. -const normalise = a => ({ status: 'open', note: '', size: 'desktop', replies: [], ...a }); + S.ann = (local ?? S.comments ?? []).map(normalise); + // Anything Claude has said back is waiting on you: open the thread so the + // words are visible and the box to answer in is already there. + for (const a of S.ann) if (awaitsReply(a)) S.threadOpen.add(a.id); +} +// state: open (still to be dealt with) · closed (Claude says it is done). +// Only Claude closes one; answering a closed one puts it back to open. +const normalise = a => ({ state: 'open', note: '', size: 'desktop', replies: [], sentAt: null, deliveredAt: null, ...a }); const lsKey = () => `vstack:review:${S.fileName}:v${S.version}`; const lsGet = () => { try { return JSON.parse(localStorage.getItem(lsKey())) } catch { return null } }; @@ -2088,9 +2257,11 @@ <h2 id="doneTitle"></h2> const real = S.ann.filter(hasSubstance); if (S.runtime === 'artifact') { try { localStorage.setItem(lsKey(), JSON.stringify(real)) } catch {} ; return } try { - await fetch(API + '/annotations', { + // The server keeps what this tab is not allowed to change: a comment + // already sent keeps its words, and only the thread grows on it. + await fetch(API + '/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: S.version, annotations: real }), + body: JSON.stringify({ comments: real }), }); } catch {} }, 700); @@ -2112,8 +2283,7 @@ <h2 id="doneTitle"></h2> presence: ev => { let watching; try { watching = JSON.parse(ev.data).watching } catch { return } - if (S.watching === watching) return; - S.watching = watching; + if (!noteWatching(watching)) return; renderWork(); }, }, @@ -2125,52 +2295,82 @@ <h2 id="doneTitle"></h2> // The link can land on its own, without the page or the review moving. adoptShare(data, true); VSShell.setWatching(data.watching); - S.watching = data.watching; + noteWatching(data.watching); VSShell.setServerVersion(data.version); if (data.historyClearedAt && data.historyClearedAt !== S.historyClearedAt) { applyHistoryClear(data); toast(T('historyCleared')); return; } - /* A missing brief means Claude picked it up and deleted it, which is the - *start* of the work, not the end of it — reading that as "round over" is - what took the progress bars off comments the moment anything else touched - the store. */ - const finishedRound = readFlight(data); // collected, active, or complete? const bumped = (data.currentVersion || 1) !== S.version; + /* A round the agent has just accounted for. In a live review this is the only + signal there is — the app is the app, and no version moves — so the banner + hangs off the summary rather than off a new picture to adopt. */ + const freshSummary = !!data.summary?.at && data.summary.at !== S.summaryAt; + if (freshSummary) S.summaryAt = data.summary.at; if (!bumped && data.html === S.html) { - // Only the conversation moved — fold it in without touching the page. - const latest = new Map(); - for (const version of Object.keys(data.reviews || {}).map(Number).sort((a, b) => a - b)) { - for (const comment of data.reviews?.[version]?.annotations || []) latest.set(comment.id, comment); - } - const merged = mergeThreads([...latest.values()]); - if (finishedRound) roundEnded(); + // Only the comments moved — fold them in without touching the page. + const merged = mergeComments(data.comments || []); if (merged.changed) { render(); if (S.sel) openComposer(S.sel, false); // The reviewer's own reply comes back the same way Claude's does. Only // announce the one the reviewer did not write. if (merged.agentSpoke) toast(T('replied')); + if (merged.closed) toast(T('closedSome')(merged.closed)); } + if (freshSummary) showWork(T('roundDone'), data.summary.text); return; } S.pendingUpdate = data; // Mid-edit saves are not news — the comments being worked on already say so. // Only a version that has actually landed is worth interrupting for. if (bumped) { - closeAddressed(data); + /* What Claude closed is news whether or not the reviewer brings the new + page in — the cards say so straight away, and the banner is only about + the picture. */ + if (mergeComments(data.comments || []).changed) render(); const meta = (data.versions || []).find(v => v.n === data.currentVersion); - showWork((live() ? T('roundReady')(data.currentVersion) : T('ready')(data.currentVersion)) - + (meta?.label ? ' — ' + esc(meta.label) : '')); - } else if (!S.awaiting) showWork(live() ? T('appChanged') : T('changed')); + /* Not "v3 is ready": a live review is the app itself and has no version the + reviewer thinks in, so the news is that the round is done, in words that + fit both. The label still says what changed. */ + showWork(T('roundDone') + (meta?.label ? ' — ' + esc(meta.label) : ''), data.summary?.text); + } else if (!S.ann.some(isWorking)) showWork(live() ? T('appChanged') : T('changed')); } -function showWork (html) { +function showWork (html, summary = null) { $('#workBanner').classList.add('on'); $('#workText').innerHTML = '<span class="tick">✓</span> ' + html; + setWorkSummary(summary); } const hideWork = () => $('#workBanner').classList.remove('on'); +/* The account of the round opens and closes on its chevron, and the way it was + left is the way the next one arrives — a reviewer who folded it away is not + asked again, and one who reads it does not have to keep opening it. The + choice is about this reviewer's screen, so it lives in the browser. */ +const SUMMARY_KEY = 'vstack:review:summary-open'; +const summaryOpen = () => { try { return localStorage.getItem(SUMMARY_KEY) !== '0' } catch { return true } }; + +function showSummary (on, remember = true) { + $('#workSummary').hidden = !on; + // The chevron turns on aria-expanded, so the state is said once. + $('#btnSummary').setAttribute('aria-expanded', String(on)); + if (remember) try { localStorage.setItem(SUMMARY_KEY, on ? '1' : '0') } catch {} +} + +function setWorkSummary (text) { + $('#workSummaryText').textContent = text || ''; + $('#btnSummary').hidden = !text; + showSummary(!!text && summaryOpen(), false); +} + +function wireWorkSummary () { + const btn = $('#btnSummary'); + btn.title = T('summaryToggle'); + btn.setAttribute('aria-label', T('summaryToggle')); + btn.onclick = () => showSummary($('#workSummary').hidden); +} + /** Is the session on the other end still there? Silent until it knows — a dot that says "lost" during the first handshake is just wrong. */ function setLink (up) { @@ -2180,121 +2380,141 @@ <h2 id="doneTitle"></h2> } /* ─────────────── work in flight ─────────────── - A sent comment is out of the reviewer's hands until Claude publishes or - answers. Saying so on the comment itself — and offering to call it off there — - beats a banner that covers the page it is talking about. */ -const setWorking = ids => { S.working = new Set(ids); renderPanel() }; - -/* Sent is not the same as being worked on. A brief sits in the store until - Claude collects it, and a comment added to a round already underway waits in - the next brief — so the workspace can tell the two apart without guessing: - anything in a brief still on disk is queued; everything else that went out - has been collected, and is being worked on. */ -function readFlight (data) { - const brief = data?.pendingReview?.comments; - const active = data?.activeReview; - const wasActive = S.activeRoundId; - if (active) { - S.activeRoundId = active.id; - S.working = new Set(active.comments || []); - S.awaiting = true; - } else if (wasActive) { - S.activeRoundId = null; - S.working.clear(); - } - const next = new Set(brief ? brief.filter(id => !S.collected.has(id)) : []); - // The cards carry this, not just the strip: collecting a brief turns "queued" - // into "being worked on", and repainting only the strip left every card - // saying queued while the strip said Claude was working on them. - const moved = next.size !== S.queued.size || [...next].some(id => !S.queued.has(id)); - S.queued = next; - if (active && !data?.pendingReview) for (const id of S.working) S.collected.add(id); - moved ? renderPanel() : renderWork(); - return !!wasActive && !active; -} -function clearWorking () { - if (!S.working.size) return; - S.working.clear(); - renderPanel(); + Where a comment has got to is written on the comment, so this reads it rather + than remembering it: sent and not yet handed over is queued, handed over is + being worked on. A reload, a second tab and the tab that pressed Send all see + the same thing. */ + +/* How long nothing may be listening before a round in hand is called stalled. + A stream watcher keeps its heartbeat up for the whole time the agent works, + so a heartbeat that stopped is a session that went away rather than a slow + one. The wait is what keeps a session restarting mid-round from reading as a + dead one. */ +const STALL_MS = 60000; +let stallTimer = null; +/** Remember when the heartbeat went quiet. Says whether anything changed. */ +function noteWatching (value) { + if (S.watching === value) return false; + S.watching = value; + S.unwatchedAt = value === false ? Date.now() : null; + return true; } + +/** How long nothing has been listening. Zero while something is. */ +const quietFor = () => S.watching === false && S.unwatchedAt ? Date.now() - S.unwatchedAt : 0; +/** A round in hand, quiet long enough to call the session behind it gone. + Everything that would animate progress asks this before claiming any. */ +const isStalled = () => quietFor() >= STALL_MS && S.ann.some(isWorking); + function renderWork () { - // A comment Claude has finished with, or asked about, is no longer in flight. - // Carried comments go out with a round too, so look for them as well — a - // reloaded workspace reads its in-flight list from the brief, which has both. - for (const id of [...S.working]) { - const a = annById(id); - if (!a || a.dismissed || a.status !== 'open') S.working.delete(id); - } - for (const id of [...S.queued]) if (!S.working.has(id)) S.queued.delete(id); - // Held comments are waiting too — just here rather than in a brief on disk. - const n = S.working.size, q = S.queued.size, h = S.held.size; - $('#pwork').hidden = !n; - if (n) $('#pworkText').textContent = - q === n && !h ? (S.watching === false ? T('notPickedUp')(q) : T('queuedOn')(q)) - : q + h ? T('workingAndQueued')(n - q, q + h) - : T('workingOn')(n); - composer.classList.toggle('working', !!S.sel && S.working.has(S.sel)); -} - -/** The round is over — however it ended. Claude publishing, replying or - closing the review all land here, and the button goes back to Send. */ -function roundEnded () { - S.awaiting = false; - clearWorking(); - renderCounts(); - // The queue fires as the round ends — one round out at a time is the deal. - flushHeld(); -} - -/** Everything held back during the round, out as one send. The set is cleared - before the send starts, so a second call is a no-op rather than a second - brief. */ -function flushHeld () { - if (!S.held.size) return; - const ids = [...S.held].filter(id => { - const a = annById(id); - return a && !a.dismissed && a.status !== 'addressed' && hasSubstance(a); - }); - S.held.clear(); - for (const a of S.ann.concat(S.carried)) delete a.held; - if (ids.length) openSend(ids); - else { save(); renderPanel(); } -} - -/** Fold in what arrived from the server without rolling back a local action - that is still inside the autosave window. The longer thread wins; a local - dismissal or explicit reopen also wins until the server has received it. */ -function mergeThreads (serverAnn) { - let changed = false, agentSpoke = false; - for (const sa of serverAnn) { - const local = annById(sa.id); - if (!local) { S.ann.push(normalise(sa)); changed = true; continue } - // Dismissal is a deliberate tombstone. A stale reload may still carry the - // visible server copy, but it must never make the card reappear. - if (local.dismissed) continue; - const sr = (sa.replies || []).length, lr = (local.replies || []).length; - // Claude has said something — open that thread in the list, where the - // answer is going to be typed. A shorter server thread is simply older - // than the reply the reviewer just wrote, so leave the local one alone. - if (sr > lr) { - local.replies = sa.replies || []; + const working = S.ann.filter(isWorking).length; + const queued = S.ann.filter(a => isQueued(a) && isOpen(a)).length; + const quiet = working > 0 ? quietFor() : 0; + const stalled = isStalled(); + /* Nothing else calls this while the page sits there waiting, so the strip has + to wake itself up to change its own mind. The heartbeat stopping is what + renders here, so the wait is armed on the round being quiet rather than on + how long it has been quiet — that is still zero on the render the presence + event triggers, and no further event is coming. */ + clearTimeout(stallTimer); + stallTimer = working && !stalled && S.watching === false + ? setTimeout(renderWork, Math.max(0, STALL_MS - quiet)) : null; + $('#pwork').hidden = !working && !queued; + $('#pwork').classList.toggle('stalled', stalled); + const again = $('#btnAgain'); + again.hidden = !stalled || !served() || isHistory(); + again.textContent = T('sendAgain'); + if (working || queued) $('#pworkText').textContent = + stalled ? T('stalled')(working) + : !working ? (S.watching === false ? T('notPickedUp')(queued) : T('queuedOn')(queued)) + : queued ? T('workingAndQueued')(working, queued) + : T('workingOn')(working); + composer.classList.toggle('working', !stalled && !!S.sel && isWorking(annById(S.sel) || {})); +} + +/** + * Give a stalled round back to the queue. + * + * A comment already handed over is not something a new watcher will ever be + * given, so a session that died holding one strands it where nobody can reach + * it. This drops only the record of the handover: the comments stay open and + * still say what they said, and the next session to pick up is given them like + * any other delivery. + */ +async function sendAgain () { + const again = $('#btnAgain'); + again.disabled = true; + try { + const response = await fetch(API + '/comments/requeue', { method: 'POST' }); + // The agent came back while the button was on screen. It holds them after + // all, so nothing is taken off it. + if (response.status === 409) { toast(T('sendAgainRefused')); return } + if (!response.ok) throw new Error(`requeue failed (${response.status})`); + const back = new Set((await response.json()).requeued || []); + for (const a of S.ann) if (back.has(a.id)) a.deliveredAt = null; + toast(T('sentAgain')); + render(); + } catch { toast(T('sendFail')) } + finally { again.disabled = false } +} + +/** + * Fold in the server's list without rolling back something typed here a moment + * ago and not yet saved. + * + * The thread is append-only on both sides, so the union of the two copies is + * the whole of it and neither can lose a line by being stale. State is the + * server's alone: this tab never closes a comment and never reopens one, so + * whatever it says goes. + */ +function mergeComments (serverComments) { + let changed = false, agentSpoke = false, closed = 0; + const seen = new Set(); + /* The server leaves a dismissed comment out of the payload, so a payload + without it is the confirmation this tab was waiting for. Stop hiding the id + then: a reply reopens a dismissed comment, and it has to be able to come + back. Anything still listed is a payload the dismissal had not reached. */ + const arriving = new Set(serverComments.map(c => c.id)); + for (const id of S.forgotten) if (!arriving.has(id)) S.forgotten.delete(id); + for (const incoming of serverComments) { + // Deleted here a moment ago, and the server has not been told yet. + if (S.forgotten.has(incoming.id)) continue; + seen.add(incoming.id); + const local = annById(incoming.id); + if (!local) { S.ann.push(normalise(incoming)); changed = true; continue } + const merged = mergeReplies(local.replies, + (incoming.replies || []).filter(r => !S.retracted.has(replyKey(r)))); + if (merged.length !== (local.replies || []).length) { + local.replies = merged; S.threadOpen.add(local.id); changed = true; - if (isAgent((sa.replies || []).at(-1)?.by)) agentSpoke = true; + if (isAgent(merged.at(-1)?.by)) agentSpoke = true; } - const localAhead = lr > sr; - const locallyReopened = local.status === 'open' && !!local.reopenedAt && sa.status === 'addressed'; - if (sa.status !== local.status && !localAhead && !locallyReopened) { - local.status = sa.status; - if (sa.status === 'question') { S.threadOpen.add(local.id); agentSpoke = true } - // A comment that has been addressed again starts clean: whatever was - // asked for on the way here has now been answered. - if (sa.status === 'addressed') { delete local.revert; delete local.reopenedAt } - changed = true; + if (incoming.state !== local.state) { + if (isClosed(incoming)) closed++; + local.state = incoming.state; changed = true; } + // Delivery, sending and closing are the server's to stamp — it is the one + // that knows. Without the close stamp every comment closed in this session + // reads as one closed long ago, and drops straight into the Earlier fold + // instead of standing where the reviewer can check it. + if (incoming.deliveredAt !== local.deliveredAt) { local.deliveredAt = incoming.deliveredAt; changed = true } + if (incoming.sentAt !== local.sentAt) { local.sentAt = incoming.sentAt; changed = true } + if (incoming.closedAt !== local.closedAt) { local.closedAt = incoming.closedAt; changed = true } } - // A question stalls the round, so it should not wait behind a closed drawer. + // Withdrawn somewhere else — another tab, or this one before a reload. + const gone = S.ann.filter(a => !seen.has(a.id) && sent(a)); + if (gone.length) { S.ann = S.ann.filter(a => seen.has(a.id) || !sent(a)); changed = true } + // A question waits on the reviewer, so it should not sit behind a closed drawer. if (agentSpoke) setPanel(true); - return { changed, agentSpoke }; + return { changed, agentSpoke, closed }; +} + +/** Two copies of one append-only thread, merged into the whole of it. */ +const replyKey = r => `${r.by}|${r.at}|${r.text}`; +function mergeReplies (mine = [], theirs = []) { + const byKey = new Map(); + for (const reply of [...mine, ...theirs]) byKey.set(replyKey(reply), reply); + return [...byKey.values()].sort((a, b) => Date.parse(a.at || 0) - Date.parse(b.at || 0)); } /** @@ -2306,36 +2526,19 @@ <h2 id="doneTitle"></h2> * one left the other putting comments back to Open the moment you looked at the * change they were not part of. */ -function closeAddressed (data) { - const closed = (data.versions || []).find(v => v.n === data.currentVersion)?.addressed || []; - /* A landed version has already dealt with these, but the page you are looking - at is still the old one and their status here is still open. The banner - announces the version; the cards say which comments it was about, and are - the second way to bring it in. Cleared when the new version is adopted. */ - for (const id of closed) S.landed.add(id); - if (closed.length) S.landedVersion = data.currentVersion; - for (const id of closed) { S.working.delete(id); S.queued.delete(id) } - if (!S.working.size) roundEnded(); - /* Always repaint. `roundEnded` only refreshes the counts, and `clearWorking` - returns early when the set is already empty — so a version that finished - everything left the cards exactly as they were: still queued, still open, - still claiming to be worked on, next to a banner saying it was all done. */ - renderPanel(); -} - function adoptUpdate () { const data = S.pendingUpdate; S.pendingUpdate = null; hideWork(); if (!data) { loadFrame(true); return } const bumped = (data.currentVersion || 1) !== S.version; - // Looking at a change is not finishing the work: a mid-round save moves the - // page and nothing else. - if (bumped) { closeAddressed(data); S.landed.clear(); S.landedVersion = null } - S.html = data.html; S.versions = data.versions; S.reviews = data.reviews; + S.html = data.html; S.versions = data.versions; S.comments = data.comments; S.version = data.currentVersion; S.name = data.name || S.name; - if (bumped) { S.viewing = S.version; loadAnnotations(); S.sentSig = null } + /* A version is a picture of the page, not a verdict on the comments: which + of those are done was already said by Claude closing them, and is on the + cards before this banner is touched. */ + mergeComments(data.comments || []); + if (bumped) S.viewing = S.version; closeComposer(); - readFlight(data); // collected, or still sitting in a brief? buildTimeline(); loadFrame(); render(); if (bumped) toast(live() ? T('nowRound')(S.version) : T('nowReviewing')(S.version)); } @@ -2390,10 +2593,12 @@ <h2 id="doneTitle"></h2> } $('#hiNew .lbl').textContent = T('hiNew'); $('#hiNew').title = T('hiNewTitle'); + renderReset(); $('#btnReload').title = T('reloadTitle'); $('#btnNow').textContent = T('backToCurrent'); $('#btnRefresh').textContent = T('reviewChanges'); $('#btnDismissWork').textContent = T('later'); + wireWorkSummary(); $('#btnCopyShare').textContent = T('copyLink'); $('#btnDismissShare').textContent = T('later'); if ($('#shareBanner').classList.contains('on')) showShareReady(); @@ -2442,6 +2647,14 @@ <h2 id="doneTitle"></h2> $('#clearDialog').onclick = e => { if (e.target === $('#clearDialog')) $('#clearDialog').close() }; $('#clearDialog').addEventListener('cancel', e => { e.preventDefault(); $('#clearDialog').close() }); $('#btnClearHistory').onclick = openHistoryDialog; + $('#btnReset').onclick = () => { VSShell.closeSettings?.(); openResetDialog() }; + $('#btnCancelReset').onclick = () => $('#resetDialog').close(); + $('#btnConfirmReset').onclick = confirmReset; + $('#resetDialog').onclick = e => { if (e.target === $('#resetDialog') && !S.resetting) $('#resetDialog').close() }; + $('#resetDialog').addEventListener('cancel', e => { + e.preventDefault(); + if (!S.resetting) $('#resetDialog').close(); + }); $('#btnCancelHistory').onclick = () => $('#historyDialog').close(); $('#btnConfirmHistory').onclick = confirmClearReviewHistory; $('#historyDialog').onclick = e => { if (e.target === $('#historyDialog') && !S.clearingHistory) $('#historyDialog').close() }; @@ -2459,7 +2672,9 @@ <h2 id="doneTitle"></h2> // One button, on the panel's edge: it pushes the comments away and pulls them // back, so there is never a second control saying the same thing elsewhere. $('#panelHandle').onclick = () => setPanel(!document.body.classList.contains('panelopen')); + wirePanelResize(); $('#btnGeneral').onclick = addGeneral; + $('#btnAgain').onclick = sendAgain; // Open beside the page when there is room for it, away when there is not — // and back to that default whenever the window crosses the line, rather than // carrying a choice made about one shape over into the other. @@ -2704,23 +2919,83 @@ <h2 id="doneTitle"></h2> result collapses the whole page to nothing. Measure at the viewport height and only let a document that genuinely overflows grow — and only if that growth settles. A layout that keeps growing as we grow it is viewport-driven, so it - stays exactly one viewport tall and scrolls its own panes, like a real browser. */ -let sizingFrame = false; + stays exactly one viewport tall and scrolls its own panes, like a real browser. + + Full height has one cost: inside the frame the CSS viewport IS the document, + so anything the page centres or pins against its viewport — a fixed overlay, + a native dialog's top layer — lands screens away from where the reviewer is + looking. While such an overlay is up, hold the frame at exactly one viewport + tall and move the outer scroll into the page itself, so the overlay sits over + what the reviewer can see; when it closes, hand the scroll back and refit. + Marks convert through the frame's own scroll at both edges, so they hold in + either state. */ +function frameModalOpen () { + const win = fwin(); + if (!win || !fdoc?.body) return false; + const shownFixed = el => { + const cs = win.getComputedStyle(el); + if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden') return false; + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + try { + // Fixed, not merely open: a non-modal dialog.show() sits in the flow and + // behaves the same here as in a real browser. + for (const el of fdoc.querySelectorAll('dialog[open], [aria-modal="true"], [role="dialog"], [role="alertdialog"]')) + if (shownFixed(el)) return true; + // A fixed layer covering the whole viewport is a backdrop even unlabelled. + // Overlays portal to the body, so only its children and grandchildren are + // worth asking. + for (const child of fdoc.body.children) + for (const el of [child, ...child.children]) + if (shownFixed(el)) { + const r = el.getBoundingClientRect(); + if (r.width >= win.innerWidth * 0.9 && r.height >= win.innerHeight * 0.9) return true; + } + } catch {} + return false; +} +let sizingFrame = false, modalViewport = false; function fitFrameHeight () { if (!fdoc) return 0; const vh = S.size.height; const apply = h => { frame.style.height = h + 'px'; scroller.style.height = h + 'px' }; const measure = () => Math.max(fdoc.documentElement.scrollHeight, fdoc.body?.scrollHeight || 0, 1); sizingFrame = true; + if (frameModalOpen()) { + // Read the outer scroll before the frame shrinks and clamps it away. + const outer = modalViewport ? 0 : vpBox.scrollTop; + modalViewport = true; + apply(vh); + if (outer) try { fwin().scrollTo(0, outer) } catch {} + requestAnimationFrame(() => { sizingFrame = false }); + return vh; + } + const inner = modalViewport ? (fwin()?.scrollY || 0) : 0; + modalViewport = false; apply(vh); const natural = measure(); if (natural > vh + 2) { apply(natural); if (measure() > natural + 2) apply(vh); } + if (inner) vpBox.scrollTop = inner; requestAnimationFrame(() => { sizingFrame = false }); return parseFloat(frame.style.height) || 0; } +/* Opening a fixed overlay changes no layout the body's ResizeObserver can see, + so the mutation watcher asks directly whether the modal state flipped. */ +let modalFitQueued = false; +function syncModalFit () { + if (modalFitQueued) return; + modalFitQueued = true; + requestAnimationFrame(() => { + modalFitQueued = false; + if (!fdoc || frameModalOpen() === modalViewport) return; + fitFrameHeight(); + drawMarks(); + }); +} let refitQueued = false; function sizeFrame () { if (!fdoc) return; @@ -3256,7 +3531,7 @@ <h2 id="doneTitle"></h2> if (!a.anchor || !fdoc) return { rect: drawn, show: true }; const el = findAnchor(a); if (!el) { - const madeHere = (a.anchorVersion ?? a.fromVersion ?? S.viewing) === S.viewing; + const madeHere = (a.anchorVersion ?? S.viewing) === S.viewing; return madeHere ? { show: false } : { rect: drawn, show: true, loose: true }; } if (!isShown(el)) return { show: false, el }; @@ -3320,7 +3595,7 @@ <h2 id="doneTitle"></h2> if (!fdoc) return; try { frame._mo?.disconnect() } catch {} // The page moved: anything we failed to find last time is worth another look. - const mo = frame._mo = new MutationObserver(() => { domEpoch++; scheduleSync() }); + const mo = frame._mo = new MutationObserver(() => { domEpoch++; syncModalFit(); scheduleSync() }); try { mo.observe(fdoc.documentElement, { childList: true, subtree: true, attributes: true, @@ -3456,17 +3731,40 @@ <h2 id="doneTitle"></h2> const c = normalise({ id: uid(), size: S.size.id, kind: 'general', ...(live() ? { route: S.route } : {}) }); c._fresh = true; + S.editingNote = c.id; S.ann.push(c); save(); render(); // the card's own field takes the cursor } +/* Deleting a comment is its own request. A save says what this tab holds, not + what the review contains — the server keeps whatever a save leaves out, which + is what a second tab depends on — so a comment dropped from the payload is + not deleted, and would come back on the next refresh. */ +/** True when the review no longer holds the comment. A server that refuses is + the one case this tab must not paper over: dropping the card anyway shows a + review the server disagrees with, and the next refresh brings the comment + back with nothing said about why. */ +async function forgetOnServer (id) { + S.forgotten.add(id); + if (S.runtime === 'artifact') return true; + try { + const response = await fetch(API + '/comments/dismiss', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + // Already gone is gone — a second click, or another tab that got there first. + if (response.ok || response.status === 404) return true; + } catch {} + S.forgotten.delete(id); + return false; +} + function removeComment (id) { S.ann = S.ann.filter(a => a.id !== id); - S.held.delete(id); if (S.sel === id) { S.sel = null; composer.classList.remove('on') } save(); render(); } -const annById = id => S.ann.find(a => a.id === id) || S.carried.find(a => a.id === id); +const annById = id => S.ann.find(a => a.id === id); /** * A comment is its words. Never typing any, or clearing the ones that were * there, means there is no comment — dismissing it takes it away. @@ -3475,32 +3773,21 @@ <h2 id="doneTitle"></h2> struck out. Words on top of one are welcome and never required. */ const SELF_EVIDENT = new Set(['move', 'strike']); const hasSubstance = a => !!(a.note || '').trim() || SELF_EVIDENT.has(a.kind); -/** Everything the current panel can actually clear. Carried comments are just - as present as current-version comments, despite living in an older file. */ -const activeComments = () => S.ann.concat(S.carried) - .filter(a => !a.dismissed && hasSubstance(a)); +/** Every comment on the list, whoever is holding it — Clear all is the × on + each card done in one go, so it reaches exactly what the cards do. */ +const listedComments = () => S.ann.filter(hasSubstance); /** A general comment that has just been made and has nothing in it yet. */ const blank = a => !!a._fresh && a.kind === 'general'; -/* Once a comment has gone to Claude it is a record of what was asked, not a - draft: editing it here would change the words without changing the brief - that already left. Reply to add to it. */ -/* Having gone out is a fact about the comment, not a phase of this tab's - memory. Deriving it from the in-flight set meant that when a round ended — - Claude publishing a version that did not name this comment — every general - comment already handed over turned back into an open text box, inviting an - edit that would never reach the brief that had already left. */ -const sent = a => !!a.sentAt || a.status !== 'open' || - !!(a.replies || []).length || !!a.reopenedAt || - S.working.has(a.id) || S.queued.has(a.id); -/* One sequence over every comment on screen, this version's and the ones - carried in from earlier ones. Which version a comment came from is the - group it sits under, not a different kind of number on its badge. */ +/* Once a comment is sent it is a record of what was asked, not a draft: the + words are what Claude was given, and the server keeps them whatever this tab + saves. Reply to add to it. `sent` is defined with the state helpers above. */ +/* One sequence over every comment on screen. */ let numbers = new Map(); function numberComments () { numbers = new Map(); let n = 0; - for (const a of S.ann.concat(S.carried)) { - if ((!hasSubstance(a) && !blank(a)) || a.dismissed) continue; + for (const a of S.ann) { + if (!hasSubstance(a) && !blank(a)) continue; numbers.set(a.id, ++n); } } @@ -3538,8 +3825,8 @@ <h2 id="doneTitle"></h2> composer.querySelector('.ckind').textContent = kindLabel(a); const st = composer.querySelector('.cstatus'); - st.className = 'cstatus' + (a.status === 'open' ? '' : ' ' + a.status); - st.textContent = a.status === 'addressed' ? T('statusAddressed') : a.status === 'question' ? T('statusAsked') : ''; + st.className = 'cstatus' + (isClosed(a) ? ' addressed' : awaitsReply(a) ? ' question' : ''); + st.textContent = isClosed(a) ? T('statusAddressed') : awaitsReply(a) ? T('statusAsked') : ''; // Say what this is attached to, so "it did not know what I meant" is answered // while the note is being written rather than after the next round. @@ -3569,24 +3856,28 @@ <h2 id="doneTitle"></h2> const thread = composer.querySelector('.cthread'); const replies = a.replies || []; thread.classList.toggle('on', replies.length > 0); - thread.innerHTML = replies.map(m => + thread.innerHTML = replies.map((m, i) => `<div class="msg ${isAgent(m.by) ? 'agent' : ''}"> <div class="who">${who(m.by)}</div> <div class="body">${esc(m.text)}</div> + ${choiceList(m, i === replies.length - 1)} </div>`).join(''); + const askedHere = replies.at(-1); + $$('.choice', thread).forEach(b => b.onclick = () => + sendPanelReply(a, askedHere?.options?.[Number(b.dataset.choice)]?.text || '')); const reply = composer.querySelector('.creply'); - const canReply = !isHistory() && (replies.length > 0 || a.status === 'question'); + const canReply = !isHistory() && (replies.length > 0 || awaitsReply(a)); reply.classList.toggle('on', canReply); reply.value = ''; - reply.placeholder = T(a.status === 'question' ? 'answerPlaceholder' : 'replyPlaceholder'); + reply.placeholder = T(awaitsReply(a) ? 'answerPlaceholder' : 'replyPlaceholder'); // Nothing written yet means dismissing already throws it away — a second // button for the same outcome is just noise. composer.querySelector('.cdel').style.display = hasSubstance(a) && !isHistory() ? '' : 'none'; composer.querySelector('.cdel').title = T('del'); composer.querySelector('.csave').style.display = isHistory() ? 'none' : ''; - composer.classList.toggle('working', S.working.has(id)); + composer.classList.toggle('working', isWorking(a) && !isStalled()); placeComposer(); if (focus !== false) setTimeout(() => (canReply && a.note ? reply : note).focus(), 0); drawMarks(); renderPanel(); @@ -3600,10 +3891,10 @@ <h2 id="doneTitle"></h2> // Both of these write, so both work on this version's copy — an edit to a // comment carried in from an earlier version is saved nowhere otherwise. if (reply) a = postReply(a, reply); - if (note !== (a.note || '')) { a = adopt(a); a.note = note; save() } + if (!sent(a) && note !== (a.note || '')) { a.note = note; save() } // No words and no thread is not feedback — clearing a comment removes it, // which is also how you take one back. - if (!hasSubstance(a)) S.ann = S.ann.filter(x => x.id !== a.id); + if (!hasSubstance(a)) { S.ann = S.ann.filter(x => x.id !== a.id); forgetOnServer(a.id) } delete a._fresh; } composer.classList.remove('on'); @@ -3615,100 +3906,76 @@ <h2 id="doneTitle"></h2> is no separate button for it, because a reply nobody reads is worse than no button at all. */ function postReply (a, text) { - /* Onto this version's copy, always. Answering is the one thing a carried - comment invites — it is on the list because Claude asked something — and a - reply written onto the ghost was saved nowhere, since `save()` posts only - `S.ann`. The answer was on screen until the next reload took it away, and - the question came back unanswered with nobody able to tell it had been. */ - const live = adopt(a); - live.replies = (live.replies || []).concat({ by: 'reviewer', text, at: new Date().toISOString() }); - if (live.status === 'question') live.status = 'open'; - else if (live.status === 'addressed') { live.status = 'open'; live.reopenedAt = new Date().toISOString() } - // A reply is new review input regardless of which reply surface wrote it. - // Reset this here so both the canvas composer and panel thread enable Send. - S.sentSig = null; + a.replies = (a.replies || []).concat({ by: 'reviewer', text, at: new Date().toISOString() }); + /* Answering something Claude called done says it is not done. The server + turns that into reopening it — the one place the rule lives — and this tab + hears the state back on the next refresh. */ save(); - return live; -} - -/** - * A comment from an earlier version becomes this version's business the moment - * you act on it — copy it in, because `save()` only ever writes the round you - * are in, and a change made to a ghost would vanish with the next reload. - */ -function adopt (a) { - const live = S.ann.find(x => x.id === a.id); - if (live) return live; - const copy = { ...a }; - delete copy.fromVersion; - S.ann.push(copy); - S.carried = S.carried.filter(x => x.id !== a.id); - return copy; + return a; } /** - * The × on a card, and the one way anything leaves this list. A comment you - * wrote and have not handed over is simply taken back; one that has entered a - * conversation, been addressed, or came from an earlier round is kept on file - * and hidden — the record of what was asked outlives the list it was asked in. + * The × on a card. It takes the comment off the list, whatever state it is in: + * a draft goes, one queued here never goes out, and an addressed one stops + * being something to read. The one Claude is working on right now is the + * exception worth saying out loud — it is off your list, and Claude carries on + * with what it was already given. */ -function takeOff (a) { +async function takeOff (a) { if (isHistory()) return; - // Only a draft that has never left this tab can disappear outright. Once it - // has words it has been autosaved, and once Claude has replied, asked, or - // addressed it there is a thread — either way keep a dismissed record, so - // neither the server nor a slightly older snapshot brings it back. - if (!a.fromVersion && !sent(a) && !hasSubstance(a)) { removeComment(a.id); return } - const live = adopt(a); - live.dismissed = true; - delete live.reopenedAt; delete live.revert; - S.threadOpen.delete(live.id); - replyDrafts.delete(live.id); - if (S.sel === live.id) closeComposer(); - S.sentSig = null; - save(); + const working = isWorking(a); + S.threadOpen.delete(a.id); + replyDrafts.delete(a.id); + if (S.sel === a.id) closeComposer(); + removeComment(a.id); + toast(T(working ? 'dismissedWorking' : 'dismissed')); + if (await forgetOnServer(a.id)) return; + // The card goes the moment it is clicked, so it has to come back the moment + // the server says it did not. + S.ann.push(a); render(); - toast(T('dismissed')); + toast(T('dismissFail')); } -/** Take back the reply you just wrote, while Claude has yet to see it. */ -function undoReply (a, i) { +/** Take back the reply you wrote. A save that omits it would read as a stale + tab and be merged straight back, so removal is its own request — the same + shape as dismissing a card. The server refuses once something has been said + over it, and then the line comes back rather than this tab disagreeing. */ +async function undoReply (a, i) { if (isHistory()) return; - const live = adopt(a); - const reps = live.replies || []; - if (i !== reps.length - 1 || reps[i]?.by !== 'reviewer') return; + const reps = a.replies || []; + const reply = reps[i]; + if (i !== reps.length - 1 || reply?.by !== 'reviewer') return; + S.retracted.add(replyKey(reply)); reps.splice(i, 1); - // Unsaying the answer puts the question back — it is unanswered again. - if (isAgent(reps.at(-1)?.by)) live.status = 'question'; - S.sentSig = null; save(); render(); + if (S.runtime === 'artifact') return; + try { + const response = await fetch(API + '/comments/unreply', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: a.id, at: reply.at, text: reply.text }), + }); + // A comment already gone took its thread with it. + if (response.ok || response.status === 404) return; + } catch {} + S.retracted.delete(replyKey(reply)); + a.replies = mergeReplies(a.replies, [reply]); + render(); + toast(T('undoReplyFail')); } /** - * Addressed is Claude's word for it, not the last word. Reopening a comment puts - * it back in play for the next round: refine adds to what was asked, revert asks - * for the change to be undone. `reopenedAt` is what lets the server take a - * status going backwards from a client — nothing else can un-address a comment. + * Closed is Claude's word for it, not the last word. Both ways back in are the + * same act — saying something on the comment — so both write into the thread + * and let the server reopen it: revert writes the line for you, refine leaves + * the cursor where your own words go. */ function reopen (a, mode) { if (isHistory()) return; - a = adopt(a); - a.status = 'open'; - a.reopenedAt = new Date().toISOString(); - if (mode === 'revert') { - a.revert = true; - postReply(a, T('revertNote')); - } else { - delete a.revert; - save(); - } - /* Both of these are things to say, so both are said in the thread: revert has - already written its line, refine leaves the cursor where yours goes. The - thread opens either way, so the ask is visible where the answer will be. */ + if (mode === 'revert') postReply(a, T('revertNote')); S.threadOpen.add(a.id); S.focusReply = a.id; - S.sentSig = null; render(); toast(T(mode === 'revert' ? 'revertAsked' : 'refineAsked')); } @@ -3760,11 +4027,10 @@ <h2 id="doneTitle"></h2> /** The one line the list shows about a comment's conversation — and the way in. */ function threadNote (a, open) { const n = (a.replies || []).length; - const asked = a.status === 'question' || (a.revert && a.status !== 'addressed'); + const asked = awaitsReply(a); // Nothing here says "addressed" — the green rule along the foot of the card // says it already, and saying it twice on one card is once too many. - const txt = a.revert && a.status !== 'addressed' ? T('wantsRevert') - : a.status === 'question' ? T('claudeAsked') + const txt = awaitsReply(a) ? T('claudeAsked') : n ? `${n} ${T(n > 1 ? 'replies' : 'reply')}` : T('addReply'); if (isHistory()) return `<div class="thread${asked ? ' asked' : ''}">${esc(txt)}</div>`; @@ -3773,8 +4039,8 @@ <h2 id="doneTitle"></h2> } /** The whole exchange, and the box to answer it in, without leaving the list. */ function threadBox (a) { - // Your last word, with nothing said back yet, is the one thing here that has - // not gone anywhere — so it is the one thing you can take back. + // Your last word, with nothing said over it yet, is the one line the thread + // can still let go of — so it is the one thing you can take back. const pending = (i, m, all) => !isHistory() && m.by === 'reviewer' && i === all.length - 1; const msgs = (a.replies || []).map((m, i, all) => `<div class="tmsg${isAgent(m.by) ? ' agent' : ''}"> @@ -3782,13 +4048,14 @@ <h2 id="doneTitle"></h2> title="${esc(T('undoReply'))}" aria-label="${esc(T('undoReply'))}">×</button>` : ''} <span class="who">${esc(who(m.by))}</span> <span class="body">${esc(m.text)}</span> + ${choiceList(m, i === all.length - 1)} </div>`).join(''); // The send sits inside the field, not under it — one row, an arrow, the way // every reply box anyone has used works. return `<div class="tbox">${msgs} <div class="treplyrow"> <textarea class="treply" rows="1" - placeholder="${esc(T(a.status === 'question' ? 'answerPlaceholder' : 'replyPlaceholder'))}"></textarea> + placeholder="${esc(T(awaitsReply(a) ? 'answerPlaceholder' : 'replyPlaceholder'))}"></textarea> <button class="tsend" title="${esc(T('sendReply'))}" aria-label="${esc(T('sendReply'))}"> <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"> @@ -3798,6 +4065,20 @@ <h2 id="doneTitle"></h2> </div> </div>`; } +/* A question can come with the answers the agent has in mind. Pressing one is + the whole reply, so the common case is a click — and the box underneath is + still there, because the answer nobody listed is the reason to ask at all. + Only the last word in the thread is a live question: options above it were + answered already, and stand as a record of what was offered. */ +function choiceList (m, isLast) { + if (!isAgent(m.by) || !m.options?.length) return ''; + const live = isLast && !isHistory(); + return '<div class="choices">' + m.options.map((option, i) => + `<button class="choice${option.recommended ? ' rec' : ''}" data-choice="${i}"${live ? '' : ' disabled'}>` + + (option.recommended ? `<em>${esc(T('recommended'))}</em>` : '') + + `<span>${esc(option.text)}</span></button>`).join('') + '</div>'; +} + const replyDrafts = new Map(); function toggleThread (id) { S.threadOpen.has(id) ? S.threadOpen.delete(id) : S.threadOpen.add(id); @@ -3811,7 +4092,6 @@ <h2 id="doneTitle"></h2> S.threadOpen.add(a.id); S.focusReply = a.id; // Answering Claude is new information — the review can go back out. - S.sentSig = null; render(); if (S.sel === a.id) openComposer(a.id, false); } @@ -3830,7 +4110,7 @@ <h2 id="doneTitle"></h2> function visibleAnn () { const list = isHistory() ? (S.reviews[S.viewing]?.annotations || []).map(normalise) - : S.ann.concat(S.carried); + : S.ann; /* Addressed comments keep their marks off the page while the Addressed group is folded: a page covered in marks for things already dealt with is a page you have to read past. Opening the group brings them back, which is exactly @@ -3839,7 +4119,7 @@ <h2 id="doneTitle"></h2> scrolls the canvas to it and opens its composer there, so hiding its mark leaves the composer pointing at nothing. */ return list.filter(a => !a.dismissed - && (S.doneOpen || isHistory() || a.status !== 'addressed' || S.sel === a.id) + && (S.doneOpen || isHistory() || isOpen(a) || S.sel === a.id) && (a.size || 'desktop') === S.size.id && !offRoute(a)); } /* Drawing a mark and placing it are two different jobs on two different clocks: @@ -3854,11 +4134,8 @@ <h2 id="doneTitle"></h2> positionMarks(); } function drawMark (a) { - const ghost = !!a.fromVersion, done = a.status !== 'open', n = num(a); - // Done covers a question too, which is still being asked. Only an addressed - // comment is finished, and only that one is drawn in the secondary voice. - const cls = (S.sel === a.id ? ' sel' : '') + (ghost ? ' ghost' : '') + (done ? ' done' : '') - + (a.status === 'addressed' ? ' addressed' : ''); + const done = isClosed(a), n = num(a); + const cls = (S.sel === a.id ? ' sel' : '') + (done ? ' done addressed' : ''); const area = a.kind === 'area' && a.rect; if (!area && !a.point) return; const shape = area ? 'area' @@ -4024,6 +4301,7 @@ <h2 id="doneTitle"></h2> numberComments(); renderWork(); const body = $('#pbody'); + const wasAt = rowTops(body), wasScrolled = body.scrollTop; body.innerHTML = ''; $('#pscope').textContent = isHistory() ? T('readOnly')(S.viewing) : sizeLabel(S.size.id); // "Open" means unfinished — a comment Claude has asked a question about is @@ -4032,7 +4310,7 @@ <h2 id="doneTitle"></h2> // own section at the bottom, where revert and refine live. // A general comment appears the moment it is made — its card IS where you // write it, so it cannot wait until it has words to be shown. - const match = a => (hasSubstance(a) || blank(a)) && a.status !== 'addressed' && !a.dismissed; + const match = a => (hasSubstance(a) || blank(a)) && isOpen(a); // A question from Claude is the one thing in this list that is waiting on // *you*. It goes first, under a heading that says how many — everything else // can be read at leisure; this can't, because the round is stalled on it. @@ -4048,13 +4326,12 @@ <h2 id="doneTitle"></h2> comment to be status `open` left carried questions out of the list while the count below went on counting them, and nothing on screen could dismiss what was never drawn. */ - const asked = S.ann.concat(S.carried).filter(a => match(a) && isAsked(a)); + const asked = S.ann.filter(a => match(a) && isAsked(a)); const here = S.ann.filter(a => match(a) && !isAsked(a) && (a.size || 'desktop') === S.size.id); const elsewhere = S.ann.filter(a => match(a) && !isAsked(a) && (a.size || 'desktop') !== S.size.id); - const carried = S.carried.filter(a => match(a) && !isAsked(a)); // Addressed in this round or in one before it — same group, same actions. - const done = S.ann.concat(S.carried).filter(a => - hasSubstance(a) && a.status === 'addressed' && !a.dismissed); + const done = S.ann.filter(a => + hasSubstance(a) && isClosed(a)); // An empty panel is empty. It used to explain how to make a comment, which is // the one thing the page itself already demonstrates the moment you click it. @@ -4062,31 +4339,37 @@ <h2 id="doneTitle"></h2> comment you wrote a second ago is the one you are still thinking about — scrolling to the bottom to find it read as an archive, not a workspace. Numbers stay put: they say which comment, not where it sits. */ - for (const list of [asked, here, elsewhere, carried, done]) list.reverse(); - const live = asked.length + here.length + elsewhere.length + carried.length; + for (const list of [asked, here, elsewhere, done]) list.reverse(); + const live = asked.length + here.length + elsewhere.length; if (live) body.appendChild(group(T('openGroup')(live))); // Waiting on you first — it is the one thing here that stalls the round. - for (const a of asked) body.appendChild(itemNode(a, { asked: true, ghost: !!a.fromVersion })); + for (const a of asked) body.appendChild(itemNode(a, { asked: true })); for (const a of here) body.appendChild(itemNode(a)); for (const a of elsewhere) body.appendChild(itemNode(a, { otherSize: true })); - for (const a of carried) body.appendChild(itemNode(a, { ghost: true })); if (done.length) { - /* What Claude just did is the part you want to check; what it did three - rounds ago is a record. The version that was published names what it - addressed, so "the last round" is a fact here rather than a guess. */ - const lastRound = new Set(S.versions.find(v => v.n === S.version)?.addressed || []); - const recent = done.filter(a => lastRound.has(a.id)); - const older = done.filter(a => !lastRound.has(a.id)); + /* What Claude has just closed is the part you want to check; what it + closed a while ago is a record. Closing stamps the comment, so each one + carries its own answer and nothing here reads the rest of the list: + measured against the newest comment still in it, taking that one off + promoted the whole batch beneath it back into view. A comment closed + before the stamp existed has none, and is a record. */ + const justClosed = a => Date.now() - Date.parse(a.closedAt || '') < 60_000; + const recent = done.filter(justClosed); + const older = done.filter(a => !justClosed(a)); body.appendChild(group(T('addressedGroup')(done.length))); - for (const a of recent) body.appendChild(itemNode(a, { done: true, ghost: !!a.fromVersion })); + for (const a of recent) body.appendChild(itemNode(a, { done: true })); if (older.length) { body.appendChild(groupToggle(T('earlierGroup')(older.length), S.doneOpen, () => { S.doneOpen = !S.doneOpen; render(); // the marks come and go with the fold })); - if (S.doneOpen) for (const a of older) body.appendChild(itemNode(a, { done: true, ghost: !!a.fromVersion })); + if (S.doneOpen) for (const a of older) body.appendChild(itemNode(a, { done: true })); } } + // Emptying the list scrolled it back to the top. Put it where the reader + // left it before anything is measured against it. + body.scrollTop = wasScrolled; + slideRowsFrom(body, wasAt); // The list is rebuilt whenever anything moves, including while a reply is // being typed into it. Put the cursor back where it was. if (S.focusReply) { @@ -4098,6 +4381,36 @@ <h2 id="doneTitle"></h2> } renderCounts(); } +/** Where every row in the list sits right now, keyed so the same row can be + found again after the rebuild. Cards carry an id; headings are told apart by + the order they appear in, because their text changes with their count. */ +function rowTops (body) { + const tops = new Map(); + let heading = 0; + for (const el of body.children) { + tops.set(el.dataset.id ? 'c' + el.dataset.id : 'h' + heading++, el.getBoundingClientRect().top); + } + return tops; +} +/** Move each row from where it used to be to where it now is. The panel is + rebuilt from scratch on every change, so a comment that changes group — the + question you have just answered leaving the top of the list — otherwise + arrives in its new place with nothing to follow. */ +function slideRowsFrom (body, tops) { + if (!tops.size || !body.firstChild?.animate) return; + if (matchMedia('(prefers-reduced-motion:reduce)').matches) return; + let heading = 0; + for (const el of body.children) { + const was = tops.get(el.dataset.id ? 'c' + el.dataset.id : 'h' + heading++); + if (was == null) continue; + const travel = was - el.getBoundingClientRect().top; + if (Math.abs(travel) < 1) continue; + // Over the rows it passes, not under them: which card is on top otherwise + // depends on which way it happens to be travelling through the list. + el.animate([{ transform: `translateY(${travel}px)`, zIndex: 1 }, { transform: 'none', zIndex: 1 }], + { duration: 280, easing: 'cubic-bezier(.22,.68,.31,1)' }); + } +} /** Only one card's menu at a time, and any click outside shuts it. */ function closeItemMenus () { for (const m of $$('.imenu')) m.hidden = true; @@ -4120,10 +4433,8 @@ <h2 id="doneTitle"></h2> b.onclick = onclick; return b; } -function itemNode (a, { ghost, otherSize, done, asked } = {}) { - // Held reads as queued: to the reviewer both mean "handed over, Claude has - // not started" — where the words are sitting meanwhile is plumbing. - const working = S.working.has(a.id), queued = S.queued.has(a.id) || S.held.has(a.id); +function itemNode (a, { otherSize, done, asked } = {}) { + const working = isWorking(a), queued = isQueued(a) && isOpen(a); // Made on another screen of the app. Not gone — one click away, and the // route it names is how you get there. const elsewhere = offRoute(a); @@ -4137,24 +4448,16 @@ <h2 id="doneTitle"></h2> (off ? ' off' : '') + (asked ? ' asked' : ''); el.dataset.id = a.id; el.style.setProperty('--mk', markColour(a)); - /* A comment carried in from an earlier version says which one on the card. - A question asked ten rounds ago otherwise looks exactly like one asked a - minute ago, and how old it is changes what you do about it. The addressed - group is already a record of earlier rounds, so it says nothing extra. */ - const raised = ghost && !done - ? ` · <span class="offtag" title="${esc(T('raised')(capFor(a.fromVersion)))}">${esc(T('fromEarlier')(capFor(a.fromVersion)))}</span>` - : ''; el.innerHTML = ` <div class="ih"> <span class="n">${num(a)}</span> - <span class="kind">${kindLabel(a)}${otherSize ? ' · ' + sizeLabel(a.size) : ''}${raised}${off ? ` · <span class="offtag">${esc(elsewhere ? T('otherScreen') : T('offscreen'))}</span>` : ''}${queued ? ` · <span class="offtag">${esc(T('queuedTag'))}</span>` : ''}</span> - ${!isHistory() && !sent(a) && !S.held.has(a.id) && hasSubstance(a) && a.status !== 'addressed' && !ghost + <span class="kind">${kindLabel(a)}${otherSize ? ' · ' + sizeLabel(a.size) : ''}${off ? ` · <span class="offtag">${esc(elsewhere ? T('otherScreen') : T('offscreen'))}</span>` : ''}${queued ? ` · <span class="offtag">${esc(T('queuedTag'))}</span>` : ''}</span> + ${!isHistory() && !sent(a) && hasSubstance(a) && isOpen(a) ? `<button class="isend" title="${esc(T('sendOne'))}" aria-label="${esc(T('sendOne'))}"> <svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"> <path d="M3 8h9M8.5 4.5 12 8l-3.5 3.5"/></svg> </button>` : ''} - ${S.landed.has(a.id) ? `<button class="landedtag" title="${esc(T('landedTitle'))}">${esc(T('landedTag')(S.landedVersion))}</button>` : ''} ${done && !isHistory() ? `<button class="imore" aria-haspopup="true" aria-expanded="false" title="${esc(T('more'))}" aria-label="${esc(T('more'))}">⋯</button>` : ''} ${isHistory() ? '' : `<button class="ikill" title="${esc(T('dismissTitle'))}" @@ -4164,13 +4467,15 @@ <h2 id="doneTitle"></h2> </div>` : ''} </div> ${live() && a.route ? `<div class="route" title="${esc(T('routeTitle'))}">${esc(a.route)}</div>` : ''} - ${a.kind === 'general' && !isHistory() && !sent(a) - ? `<textarea class="gnote" rows="2" placeholder="${esc(T('notePlaceholder'))}">${esc(a.note || '')}</textarea>` + ${a.kind === 'general' && !isHistory() && !sent(a) && (blank(a) || S.editingNote === a.id) + ? `<textarea class="gnote" rows="2" placeholder="${esc(T('notePlaceholder'))}">${esc(a.note || '')}</textarea> + <div class="gfoot"><span class="hint">${esc(T('newlineHint'))}</span> + <button class="gsave">${esc(T('save'))} <span class="kbd">⏎</span></button></div>` : `<div class="note${a.note || SELF_EVIDENT.has(a.kind) ? '' : ' blank'}">${esc(a.note || unwritten(a))}</div>`} ${threadNote(a, open)} ${open ? threadBox(a) : ''} ${done ? '<div class="donebar"></div>' : ''} - ${working && !queued ? '<div class="bar"></div>' : ''}`; + ${working && !queued && !isStalled() ? '<div class="bar"></div>' : ''}`; const menu = $('.imenu', el), more = $('.imore', el); if (more) more.onclick = e => { e.stopPropagation(); @@ -4197,23 +4502,30 @@ <h2 id="doneTitle"></h2> gnote.addEventListener('input', () => { a.note = gnote.value.trim(); if (a._fresh && a.note) delete a._fresh; - S.sentSig = null; save(); renderCounts(); }); gnote.addEventListener('blur', () => { if (!hasSubstance(a)) removeComment(a.id) }); + /* Enter saves and stops there. Sending is the reviewer's own act, on their + own timing, for the whole list at once — a general comment is not the one + kind that leaves on its own. Shift+Enter is the newline, as everywhere + else in the workspace. */ + const finish = () => { + a.note = gnote.value.trim(); + if (a._fresh && a.note) delete a._fresh; + if (!hasSubstance(a)) { gnote.blur(); return } // the blur takes the empty card away + S.editingNote = null; + save(); + render(); // the box closes and the words stand as the comment + }; gnote.addEventListener('keydown', e => { if (e.key === 'Escape') { gnote.blur(); return } if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return; - // Enter sends: this box has no Save button beside it, so the key that - // finishes a thought should be the key that hands it over. Shift+Enter - // is the newline, as everywhere else in the workspace. e.preventDefault(); - a.note = gnote.value.trim(); - if (a._fresh && a.note) delete a._fresh; - gnote.blur(); - if (hasSubstance(a)) { save(); openSend() } + finish(); }); - if (a._fresh) setTimeout(() => gnote.focus({ preventScroll: true }), 0); + const gsave = $('.gsave', el); + if (gsave) gsave.onclick = e => { e.stopPropagation(); finish() }; + if (a._fresh || S.editingNote === a.id) setTimeout(() => gnote.focus({ preventScroll: true }), 0); } const send1 = $('.isend', el); if (send1) send1.onclick = e => { e.stopPropagation(); openSend(a.id) }; @@ -4243,12 +4555,22 @@ <h2 id="doneTitle"></h2> e.stopPropagation(); undoReply(a, Number(b.dataset.ri)); }); + // Picking an option is answering with those words, so it takes the same + // path a typed answer does and reads the same in the thread afterwards. + const asked = (a.replies || []).at(-1); + $$('.choice', box).forEach(b => b.onclick = e => { + e.stopPropagation(); + sendPanelReply(a, asked?.options?.[Number(b.dataset.choice)]?.text || ''); + }); } el.onclick = () => { - if (a.kind === 'general') { $('.gnote', el)?.focus(); return } + if (a.kind === 'general') { + if ($('.gnote', el)) { $('.gnote', el).focus(); return } + if (!isHistory() && !sent(a)) { S.editingNote = a.id; render() } + return; + } // Carried and still open: say where it came from, because the version it was // made on is not the one on screen. Carried and addressed: nothing to say. - if (ghost && a.status === 'open') { toast(T('raised')(capFor(a.fromVersion))); return } if ((a.size || 'desktop') !== S.size.id) { setSize(a.size); setTimeout(() => reveal(a), 320); return } // Made on a screen that isn't up: go there first, then show the mark. if (elsewhere) { toast(T('goingTo')(a.route)); navigate(a.route, () => reveal(a)); return } @@ -4295,69 +4617,75 @@ <h2 id="doneTitle"></h2> return false; } function renderCounts () { - const comments = activeComments(); - const open = comments.filter(a => a.status !== 'addressed'); + // Every comment still to be dealt with, whoever is holding it — and, apart + // from it, everything on the list, which is what Clear all takes off. + const open = S.ann.filter(a => isOpen(a) && hasSubstance(a)); + const clearable = listedComments(); $('#counts').innerHTML = open.length ? `<b>${open.length}</b> ${esc(T('open').toLowerCase())}` - + (comments.some(a => a.status === 'question') ? '<br>' + esc(T('waiting')) : '') + + (open.some(a => awaitsReply(a)) ? '<br>' + esc(T('waiting')) : '') : esc(T('noOpen')); const clear = $('#btnClear'); clear.textContent = T('clearAll'); - clear.disabled = isHistory() || !comments.length; + clear.disabled = isHistory() || !clearable.length; renderSendButton(open); renderPanelBadge(open); renderHistoryButton(); } +/* What Clear all would take. Addressed comments are a record of finished work, + so clearing them loses nothing; an open one is still wanted, and goes only + when the reviewer says so on the dialog. */ +const clearScope = () => { + const listed = listedComments(); + return $('#clearOpenToo')?.checked ? listed : listed.filter(isClosed); +}; + function renderClearDialog () { const dialog = $('#clearDialog'); if (!dialog.open) return; $('#clearDialogTitle').textContent = T('clearDialogTitle'); - $('#clearDialogImpact').textContent = T('clearImpact')(activeComments().length); + $('#clearDialogImpact').textContent = T('clearImpact'); + $('#clearOpenTooLabel').textContent = T('clearOpenToo'); $('#clearDialogKept').textContent = T('clearKept'); $('#btnCancelClear').textContent = T('clearCancel'); $('#btnConfirmClear').textContent = T('clearConfirm'); } function openClearDialog () { - if (isHistory() || !activeComments().length) return; + if (isHistory() || !listedComments().length) return; const dialog = $('#clearDialog'); + // A default, not a preference: every time it is asked, the open ones stay. + $('#clearOpenToo').checked = false; if (!dialog.open) dialog.showModal(); renderClearDialog(); $('#btnCancelClear').focus(); } -function confirmClearComments () { +async function confirmClearComments () { if (isHistory()) return; + const going = clearScope(); closeComposer(); - const n = activeComments().length; $('#clearDialog').close(); - if (!n) return; - /* A carried comment lives in an earlier review file. Emptying only this - version cannot remove it; on reload it is carried straight back in. Put a - dismissed copy in the current version instead. Doing that for every - comment also preserves the audit record and prevents an older copy of a - current comment from being resurrected after the current one is gone. */ - const cleared = new Map(); - for (const a of S.carried.concat(S.ann)) { - if (!hasSubstance(a)) continue; - const copy = { ...a, dismissed: true }; - delete copy.fromVersion; delete copy.reopenedAt; delete copy.revert; - delete copy.held; delete copy._fresh; - cleared.set(copy.id, copy); - } - S.ann = [...cleared.values()]; - S.carried = []; - S.held.clear(); S.sentSig = null; + if (!going.length) return; + S.ann = S.ann.filter(a => !going.includes(a)); save(); render(); - toast(T('cleared')(n)); + toast(T('cleared')(going.length)); + // Same honesty as the × on one card: whatever the server would not take off + // is still on the review, so it goes back on the list. + const results = await Promise.all(going.map(a => forgetOnServer(a.id).then(ok => ({ a, ok })))); + const kept = results.filter(r => !r.ok).map(r => r.a); + if (!kept.length) return; + S.ann.push(...kept); + render(); + toast(T('clearFail')(kept.length)); } /* The button has to say what is behind it, because a folded-away panel says nothing itself. The badge counts what is waiting on *you* — a question from Claude — and falls back to how many comments are open. */ function renderPanelBadge (open) { - const need = S.ann.concat(S.carried).filter(a => !a.dismissed && awaitsReply(a)).length; + const need = S.ann.filter(awaitsReply).length; const say = need ? T('awaitingYou')(need) : open.length ? `${open.length} ${T('open').toLowerCase()}` : T('noOpen'); @@ -4378,6 +4706,61 @@ <h2 id="doneTitle"></h2> $('#panelHandle').setAttribute('aria-expanded', String(on)); } +/* ── how wide the comments are ── + Narrow enough and a comment is unreadable; wide enough and there is no page + left to read it against. So the width is the reviewer's to set, between those + two, and it is remembered per browser rather than per review — it is about + this screen, not about this design. */ +const PANEL_MIN = 260, PANEL_KEY = 'vstack:review:panelw'; +const panelMax = () => Math.max(PANEL_MIN, Math.min(720, Math.round(window.innerWidth * 0.6))); + +function setPanelWidth (px, remember = true) { + const w = Math.min(panelMax(), Math.max(PANEL_MIN, Math.round(px))); + document.documentElement.style.setProperty('--panelw', w + 'px'); + $('#panelGrip')?.setAttribute('aria-valuenow', String(w)); + if (remember) try { localStorage.setItem(PANEL_KEY, String(w)) } catch {} + return w; +} + +function wirePanelResize () { + const grip = $('#panelGrip'), panel = $('#panel'); + if (!grip) return; + grip.setAttribute('aria-label', T('panelWidth')); + const saved = (() => { try { return Number(localStorage.getItem(PANEL_KEY)) } catch { return 0 } })(); + if (saved) setPanelWidth(saved, false); + + // Pointer capture, so a drag that outruns the grip — or leaves the window — + // still ends on this element rather than being lost. + grip.addEventListener('pointerdown', e => { + if (e.button) return; + e.preventDefault(); + grip.setPointerCapture(e.pointerId); + document.body.classList.add('resizing'); + const startX = e.clientX, startW = panel.getBoundingClientRect().width; + const drag = ev => setPanelWidth(startW + (startX - ev.clientX)); + const stop = () => { + grip.removeEventListener('pointermove', drag); + document.body.classList.remove('resizing'); + }; + grip.addEventListener('pointermove', drag); + grip.addEventListener('pointerup', stop, { once: true }); + grip.addEventListener('pointercancel', stop, { once: true }); + }); + + grip.addEventListener('keydown', e => { + const step = e.key === 'ArrowLeft' ? 16 : e.key === 'ArrowRight' ? -16 : 0; + if (!step) return; + e.preventDefault(); + setPanelWidth(panel.getBoundingClientRect().width + step); + }); + + // A window that shrinks can leave the panel wider than the cap allows. + window.addEventListener('resize', () => { + const now = panel.getBoundingClientRect().width; + if (now > panelMax()) setPanelWidth(now, false); + }); +} + /* Send stays Send, even with a round out. Noticing something else while Claude works is the normal case, not an interruption to be blocked — the comment goes out and joins the round. */ @@ -4390,9 +4773,8 @@ <h2 id="doneTitle"></h2> `<span class="narrow">${T(artifact ? 'copyForShort' : 'sendShort')}</span>` + ' <span class="kbd">⌘⏎</span>'; btn.title = ''; - const nothing = !open.length && !S.carried.length; - // Nothing new to say since the last send is the only reason to be disabled. - btn.disabled = nothing || reviewSignature() === S.sentSig; + // Everything already let go of is on its way; Send is for what is still here. + btn.disabled = !open.some(a => !sent(a) && hasSubstance(a)); } /* ─────────────── version timeline ─────────────── */ @@ -4403,7 +4785,7 @@ <h2 id="doneTitle"></h2> function renderHistoryButton () { const btn = $('#btnClearHistory'); if (!btn) return; - btn.hidden = !served() || S.runtime === 'phase'; + btn.hidden = !served() || S.runtime === 'phase' || live(); if (btn.hidden) return; btn.disabled = S.clearingHistory || !hasVersionHistory(); btn.textContent = S.clearingHistory ? T('clearHistoryWorking') : T('clearHistory'); @@ -4411,6 +4793,65 @@ <h2 id="doneTitle"></h2> btn.setAttribute('aria-label', btn.textContent); } +/* Starting over. A tool update that changes what a review keeps on disk leaves + a store written by the version before it, and reading one is a courtesy, not + a promise — this is the way out when the courtesy is not enough. It is behind + the cog because it is not part of reviewing, and behind a confirmation + because nothing it deletes comes back. */ +function renderReset () { + const btn = $('#btnReset'); + if (!btn) return; + btn.textContent = S.resetting ? T('resetWorking') : T('reset'); + btn.title = T('resetTitle'); + btn.disabled = S.resetting || !served(); +} + +function renderResetDialog () { + const dialog = $('#resetDialog'); + if (!dialog.open) return; + $('#resetDialogTitle').textContent = T('resetDialogTitle'); + const comments = S.ann.filter(hasSubstance).length; + // A live review has no versions and no page of its own to speak for. + $('#resetDialogImpact').textContent = live() + ? T('resetImpactLive')(comments) + : T('resetImpact')(comments, S.versions.length); + $('#resetDialogKept').textContent = live() ? T('resetKeptLive') : T('resetKept'); + $('#btnCancelReset').textContent = T('resetCancel'); + $('#btnCancelReset').disabled = S.resetting; + $('#btnConfirmReset').textContent = S.resetting ? T('resetWorking') : T('resetConfirm'); + $('#btnConfirmReset').disabled = S.resetting; +} + +function openResetDialog () { + if (!served() || S.resetting) return; + const dialog = $('#resetDialog'); + if (!dialog.open) dialog.showModal(); + renderResetDialog(); + $('#btnCancelReset').focus(); +} + +async function confirmReset () { + if (S.resetting) return; + S.resetting = true; + renderReset(); renderResetDialog(); + try { + const response = await fetch(API + '/reset', { method: 'POST' }); + if (response.status === 404) throw new Error('old'); + if (!response.ok) throw new Error(`reset failed (${response.status})`); + } catch (error) { + const old = error?.message === 'old'; + S.resetting = false; + renderReset(); renderResetDialog(); + toast(T(old ? 'resetOld' : 'resetFail')); + return; + } + /* Everything this tab holds is about the review that just went — the frame, + the timeline, the cards, the drafts. Reading it back in is the reload it + would take anyway, and it is the one moment where losing the page is the + point rather than a rudeness. */ + location.reload(); +} + function renderHistoryDialog () { const dialog = $('#historyDialog'); if (!dialog.open) return; @@ -4490,16 +4931,24 @@ <h2 id="doneTitle"></h2> return (S.versions.find(v => v.n === n)?.goal || '') + (n === S.version ? (S.versions.find(v => v.n === n)?.goal ? ' · ' : '') + T('phaseCur') : ''); } - const openN = (S.reviews[n]?.annotations || []).filter(a => a.status === 'open').length; + const openN = S.ann.filter(a => isOpen(a) && hasSubstance(a)).length; return dateFor(n) + (openN ? ` · ${openN} open` : '') + (n === S.version ? ' · ' + T('current') : ''); } const capFor = n => S.runtime === 'phase' ? `P${n}` : live() ? `r${n}` : `v${n}`; +/* A version is a frozen copy of the page under review. A running app has none — + what a capture of one produces is a likeness, and scrubbing back to it showed + something that was never really there — so a live review has comments and no + timeline at all. */ const scrubItems = () => - versionList().map(n => ({ id: n, cap: capFor(n), label: labelFor(n), sub: subFor(n) })); + live() ? [] : versionList().map(n => ({ id: n, cap: capFor(n), label: labelFor(n), sub: subFor(n) })); function buildTimeline () { + /* A live review has no versions to move between, so the whole row goes rather + than sitting there empty with a track that does nothing. */ + const timeline = $('#timeline'); + if (timeline) timeline.hidden = live(); VSScrub.set({ items: scrubItems(), active: S.viewing }); syncTimeline(); } @@ -4575,21 +5024,13 @@ <h2 id="doneTitle"></h2> Landed counts as done: a version this tab has not adopted yet already addressed it, and a brief built between the announcement and the refresh — the held queue flushing is exactly that moment — must not hand it back. */ - const done = a => a.status === 'addressed' || S.landed.has(a.id); - const items = S.ann.filter(a => !done(a) && !a.dismissed && hasSubstance(a) && a.sentAt); - const carried = S.carried.filter(a => !done(a) && !a.dismissed); - - const reverts = items.concat(carried).filter(a => a.revert); - const reopened = items.concat(carried).filter(a => a.reopenedAt && !a.revert); + const items = S.ann.filter(a => isOpen(a) && hasSubstance(a) && sent(a)); const L = []; L.push(live() - ? `# Live UI review — ${S.name} · round ${S.version}` + ? `# Live UI review — ${S.name} · v${S.version}` : `# Wireframe review — ${S.name} · v${S.version}`); - L.push(`${items.length} comment(s) — every one is a must` + - (carried.length ? ` · ${carried.length} carried over` : '') + - (reverts.length ? ` · ${reverts.length} revert` : '') + - (reopened.length ? ` · ${reopened.length} reopened` : '')); + L.push(`${items.length} open comment(s) — every one is a must`); L.push(''); if (live()) { // What is under review is running code, so the change is a code change. @@ -4607,7 +5048,7 @@ <h2 id="doneTitle"></h2> /* Two of the marks are drawn instead of written, so a brief that carries one has to say what the drawing meant — a comment with no words is otherwise read as a comment nobody finished. */ - const drawn = items.concat(carried).filter(a => a.kind === 'move' || a.kind === 'strike'); + const drawn = items.filter(a => a.kind === 'move' || a.kind === 'strike'); if (drawn.length) { L.push(''); L.push('Some of these were drawn on the page rather than written: **Move it** is an arrow ' + @@ -4615,12 +5056,6 @@ <h2 id="doneTitle"></h2> 'instructions in their own right — a note on one adds to it, and no note means there was ' + 'nothing to add.'); } - if (reverts.length || reopened.length) { - L.push(''); - L.push('Some of these you already marked addressed and they were sent back: ' + - '**revert** means undo what you did there, **reopened** means it did not go far enough. ' + - 'Read the note and the thread again before touching anything.'); - } L.push(''); const bySize = {}; @@ -4638,10 +5073,7 @@ <h2 id="doneTitle"></h2> in an earlier brief — the brief is the state of the review, not a diff. Say which ones you have already seen so a second read does not become a second round of work on the same comment. */ - const again = S.collected.has(a.id); - L.push(`### #${num(a)}${a.revert ? ' · REVERT' : ''}${again ? ' · ALREADY SENT' : ''}`); - if (again) L.push('*You have had this one before and it is still open — carry on with it rather than starting again.*'); - if (a.revert) L.push('**They asked for your last change here to be undone** — restore what was there before, then apply the note again only if it still stands.'); + L.push(`### ${a.id}`); if (live() && a.route) L.push(`**Route** \`${a.route}\``); L.push(`**Where** ${whereLine(a)}`); if (a.kind === 'move') L.push(`**Move it** ${moveLine(a)}`); @@ -4655,35 +5087,21 @@ <h2 id="doneTitle"></h2> L.push(''); } } - if (carried.length) { - L.push(live() ? '## Still open from earlier rounds' : '## Still open from earlier versions'); - L.push(''); - for (const a of carried) { - L.push(`- **[${capFor(a.fromVersion)}]** ${a.note} — ${live() && a.route ? '`' + a.route + '` · ' : ''}${whereLine(a)}`); - } - L.push(''); - } L.push('---'); - L.push('Claim the round id from the pending record first. Then mark every applied comment done, and ask about anything unclear instead of guessing:'); + L.push('Close what you have done. Anything you do not name stays open and comes back next time,'); + L.push('so ask about whatever is unclear instead of guessing:'); L.push('```bash'); const subject = live() ? '--name <review-name>' : '--file <page.html>'; - L.push(`node review-server.mjs claim ${subject} --round <round-id>`); - L.push(`node review-server.mjs publish ${subject} --round <round-id> --label "<what changed>" --addressed ` + - items.concat(carried).map(a => a.id).join(',')); - L.push(`node review-server.mjs reply ${subject} --round <round-id> --comment <id> --text "<your question>"`); + L.push(`node review-server.mjs publish ${subject} --close <ids> --label "<what changed>"`); + L.push(`node review-server.mjs reply ${subject} --comment <id> --text "<your question>"`); L.push('```'); const payload = { page: S.fileName, name: S.name, version: S.version, - ...(live() ? { reviewing: 'app', app: S.app, routes: [...new Set(items.concat(carried).map(a => a.route).filter(Boolean))] } : {}), - comments: items.concat(carried).map(a => ({ - id: a.id, kind: a.kind, status: a.status, note: a.note, + ...(live() ? { reviewing: 'app', app: S.app, routes: [...new Set(items.map(a => a.route).filter(Boolean))] } : {}), + comments: items.map(a => ({ + id: a.id, kind: a.kind, state: a.state, note: a.note, replies: a.replies || [], - // Reopened after you called it done — either put it back (revert) or - // take it further. Either way it is not finished. - reopened: !!a.reopenedAt, wantsRevert: !!a.revert, - // True when this comment was in a brief you already collected. - alreadySent: S.collected.has(a.id), anchorText: a.anchorText || null, // What the comment is attached to. `selector` is how the workspace finds // the element again to keep the mark on it — a hint for you, not a promise @@ -4717,164 +5135,60 @@ <h2 id="doneTitle"></h2> // Live: the screen of the app this was said about. route: a.route || null, point: a.point || null, rect: a.rect || null, - fromVersion: a.fromVersion || S.version, + seenAt: a.seenAt || S.version, })), }; return { markdown: L.join('\n') + '\n\n```json\n' + JSON.stringify(payload, null, 2) + '\n```\n', feedback: payload, - counts: { total: items.length + carried.length }, + counts: { total: items.length }, }; } -/** What was last handed over, so an unchanged review can't be sent twice. */ -const reviewSignature = () => JSON.stringify(S.ann - .filter(a => a.status !== 'addressed' && hasSubstance(a)) - .map(a => [a.id, a.note, a.status, !!a.revert, (a.replies || []).length])); - -/* ─────────────── live: keeping a copy of what they saw ─────────────── - A file review freezes the file on every publish. There is nothing to freeze - here — the app moves on the moment the code does — so the workspace takes - the picture instead: the DOM as it stands, with its stylesheets folded in so - it still looks like itself. It is what the timeline scrubs back to, and what - gets published when someone asks for a link they can send on. - - A capture is a likeness, not the app: scripts are dropped, and anything the - page would have fetched later never arrives. */ -function captureDOM () { - if (!fdoc) return null; - let doc; - try { doc = fdoc.documentElement.cloneNode(true) } catch { return null } - for (const n of doc.querySelectorAll('script')) n.remove(); - // Same-origin means the rules are readable — which is the only reason a - // capture of a real app looks like anything at all. - const css = []; - try { - for (const sheet of fdoc.styleSheets) { - let rules = null; - try { rules = sheet.cssRules } catch { continue } // a cross-origin sheet stays out - if (rules) css.push([...rules].map(r => r.cssText).join('\n')); - } - } catch {} - for (const n of doc.querySelectorAll('link[rel~="stylesheet"],style')) n.remove(); - const head = doc.querySelector('head') || doc; - // Absolute paths in the capture still point at this origin, which is the - // proxy — so a capture opened outside it shows structure, not artwork. - const base = doc.ownerDocument.createElement('base'); - base.href = location.origin + '/'; - head.prepend(base); - const style = doc.ownerDocument.createElement('style'); - style.textContent = css.join('\n'); - head.append(style); - return '<!doctype html>\n' + doc.outerHTML; -} -/** Best-effort — a round with no capture is still a round. */ -async function pushCapture () { - if (!live() || isHistory()) return; - const html = captureDOM(); - if (!html) return; - try { - await fetch(API + '/snapshot', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: S.version, route: S.route, html }), - }); - } catch {} -} /** - * Hand comments over. With no argument it sends everything open; with an id it - * sends that one, which joins whatever is already out rather than replacing it. - * With an array of ids — the held queue flushing — it sends those, and never - * holds. + * Let go of what has been written. With no argument that is everything still + * here; with an id it is that one, which joins whatever Claude already has. + * Sending is not a batch — the comment joins the list the server holds, and + * goes out with everything else still open on the next tick. */ async function openSend (only) { closeComposer(); const at = new Date().toISOString(); // DOM event handlers pass the click event as their first argument. Only an - // explicit string/array is a scoped send; everything else means Send all. - const scope = Array.isArray(only) || typeof only === 'string' ? only : null; - const going = Array.isArray(scope) - ? scope.map(annById).filter(Boolean) - : scope - ? [annById(scope)].filter(Boolean) - : S.ann.filter(a => a.status !== 'addressed' && !a.dismissed && hasSubstance(a)); - /* A collected round is a Claude already working — sending into it would raise - a second wake-up mid-round. So the comment waits here, marked queued, and - goes out as one batch the moment the round ends. A brief still sitting - uncollected is different: writing into it just grows the batch Claude will - pick up in one read, so those sends go straight through. */ - const collected = [...S.working].some(id => !S.queued.has(id)); - if (!Array.isArray(scope) && collected && served()) { - for (const a of going) { - if (S.working.has(a.id) || S.held.has(a.id)) continue; - a.held = true; S.held.add(a.id); - } - if (S.held.size) { - // Nothing new to say until the next edit — the button can rest. - S.sentSig = reviewSignature(); - save(); renderPanel(); - toast(T('heldOn')(S.held.size)); - } - return; - } - for (const a of going) if (!a.sentAt) a.sentAt = at; - const pending = buildFeedback(); - if (!pending.counts.total) return; + // explicit id is a scoped send; everything else means send all. + const scope = typeof only === 'string' ? only : null; + const candidates = scope ? [annById(scope)].filter(Boolean) : S.ann; + const going2 = candidates.filter(a => !sent(a) && hasSubstance(a) && isOpen(a)); + if (!going2.length) return; if (S.runtime === 'artifact') { + const pending = buildFeedback(); await copy(pending.markdown); - S.sentSig = reviewSignature(); // Copied out is out: the words are on their way to Claude in a paste, and // editing them here afterwards would change only this screen. - const at = new Date().toISOString(); - for (const c of pending.feedback.comments) { - const a = annById(c.id); - if (a && !a.sentAt) a.sentAt = at; - } + for (const a of going2) a.sentAt = at; save(); render(); return; } try { - // Take the picture before the round starts — once Claude changes the code, - // what the reviewer was looking at is gone. - await pushCapture(); - /* The held queue flushes at the announcement, before this tab adopts the - new version — filing it under S.version then would overwrite the round - that just ended. A send always belongs to the newest version there is. */ - const v = S.pendingUpdate?.currentVersion ?? S.version; - /* Same moment, same stale statuses: what landed is addressed, and writing - it as open into the new version's record would resurrect it there. */ - const anns = S.ann.filter(hasSubstance) - .map(a => S.landed.has(a.id) ? { ...a, status: 'addressed' } : a); - const response = await fetch(API + '/feedback', { + /* Letting go of a comment is the whole of sending it. It joins the list the + server already holds and goes out on the next tick, so there is no batch + to time and nothing to hold back — Claude gets everything still open, + whenever each of them was written. */ + for (const a of going2) a.sentAt = at; + const response = await fetch(API + '/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: v, annotations: anns, ...pending }), + body: JSON.stringify({ comments: S.ann.filter(hasSubstance) }), }); - if (!response.ok) throw new Error(`feedback failed (${response.status})`); + if (!response.ok) throw new Error(`send failed (${response.status})`); const receipt = await response.json(); - S.activeRoundId = receipt.roundId || S.activeRoundId; - S.awaiting = true; - S.sentSig = reviewSignature(); - /* Everything that just went out is queued by definition: the brief is on - disk and nobody has opened it. Waiting for the server to tell us that on - the next tick left the bars sweeping for a second first — saying work had - started when the message had not even been collected. Anything Claude - already holds from an earlier brief stays as work in progress. */ - const ids = pending.feedback.comments.map(c => c.id); - // Stamped on the comment itself, so it stays true after the round it went - // out with is over. Out is out — whatever was held is held no longer. - const at = new Date().toISOString(); - for (const id of ids) { - const a = annById(id); - if (a) { if (!a.sentAt) a.sentAt = at; delete a.held } - S.held.delete(id); - } - S.queued = new Set(ids.filter(id => !S.collected.has(id))); - setWorking(ids); - save(); - toast(T('working')(pending.counts.total)); + mergeComments(receipt.comments || []); + render(); + toast(T('working')(going2.length)); } catch { - // Failed is unsent: whatever the signature said, Send has to work again. - S.sentSig = null; + // Failed is unsent, so the button has to work again. + for (const a of going2) a.sentAt = null; + render(); toast(T('sendFail')); } } @@ -4902,17 +5216,7 @@ <h2 id="doneTitle"></h2> $('#btnApprove .t').textContent = armed ? T('approveSure') : T('approve'); $('#btnApprove .d').textContent = ''; } -/* The hold is the default, not a wall. When Claude needs what was just written - to finish the round it is in the middle of, the queue can go now — it lands - as the next brief and joins the round rather than waiting behind it. */ -function renderSendNow () { - const b = $('#btnSendNow'), n = S.held.size; - b.hidden = !n; $('#sendNowRule').hidden = !n; - if (!n) return; - b.querySelector('.t').textContent = T('sendNow'); - b.querySelector('.d').textContent = T('sendNowDesc')(n); -} -$('#btnSendNow').onclick = () => { closeSendMenu(); flushHeld(); }; + $('#btnSendMore').onclick = e => { e.stopPropagation(); const on = !sendMenu.classList.contains('on'); @@ -4921,7 +5225,7 @@ <h2 id="doneTitle"></h2> sendMenu.classList.add('on'); $('#btnSendMore').setAttribute('aria-expanded', 'true'); clearShareFlag(); - renderApprove(); renderShare(); renderSendNow(); + renderApprove(); renderShare(); }; // Signing off ends the review for everyone, so it asks once before acting. const approveConfirm = VSShell.armConfirm($('#btnApprove'), { @@ -4981,9 +5285,6 @@ <h2 id="doneTitle"></h2> if (s.url && s.version === S.version) { copy(s.url); closeSendMenu(); return } S.share.pending = true; renderShare(); try { - // A running app cannot be sent to anyone. What can is a capture of the - // screen they are on — so take one now, and let Claude publish that. - await pushCapture(); await fetch(API + '/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: S.version }), @@ -4996,8 +5297,8 @@ <h2 id="doneTitle"></h2> async function approve () { closeSendMenu(); - const all = new Map(S.carried.concat(S.ann).map(a => [a.id, a])); - const open = [...all.values()].filter(a => !a.dismissed && a.status !== 'addressed' && hasSubstance(a)); + const all = new Map(S.ann.map(a => [a.id, a])); + const open = [...all.values()].filter(a => isOpen(a) && hasSubstance(a)); if (S.runtime === 'artifact') { showDone(); return } try { const response = await fetch(API + '/approve', { diff --git a/plugins/vstack/skills/review/hosts/claude.md b/plugins/vstack/skills/review/hosts/claude.md index 0001eed..2681905 100644 --- a/plugins/vstack/skills/review/hosts/claude.md +++ b/plugins/vstack/skills/review/hosts/claude.md @@ -20,9 +20,9 @@ Default when unset is `claude`, so existing installs keep working without this. | Host op | Claude Code tool | How | | --- | --- | --- | | `background(cmd)` | Bash / shell with `run_in_background: true` | `node …/review-server.mjs serve …` must outlive the turn | -| `watch_stream(cmd)` | **Monitor** tool, `persistent: true` | `node …/review-server.mjs watch --all --stream` — Monitor delivers each line to the session as it arrives | +| `watch_stream(cmd)` | **Monitor** tool, `persistent: true` | `node …/review-server.mjs watch --all --stream --session "$CLAUDE_CODE_SESSION_ID"` — Monitor delivers each line to the session as it arrives. `CLAUDE_CODE_SESSION_ID` is set in every shell and is the id the Stop hook receives, which is what binds each delivery to this session | | `stop(handle)` | TaskStop / stop the background task | After approve or when ending the session | -| `run(cmd)` | Bash (foreground) | `publish`, `reply`, `ack`, `share`, `check`, `status` | +| `run(cmd)` | Bash (foreground) | `publish`, `reply`, `ack`, `share`, `status`, `unanswered` | | `edit` | Edit / Write tools | Change the HTML file or app source | | `share(file)` | **Artifact** tool (favicon 🎨) | Publish the wireframe file; then `share --url <url>` | | `browser_capture` | Claude-in-Chrome / browser tools | Navigate, screenshot, run `harvest-reference.js` | @@ -41,7 +41,7 @@ node "$SKILL/assets/review-server.mjs" publish --file "$FILE" --label "Initial v node "$SKILL/assets/review-server.mjs" serve --file "$FILE" --port 7788 --host claude # watch_stream (Monitor, persistent: true): -node "$SKILL/assets/review-server.mjs" watch --all --stream +node "$SKILL/assets/review-server.mjs" watch --all --stream --session "$CLAUDE_CODE_SESSION_ID" # then answer the HANDSHAKE line it prints, with Bash (foreground): node "$SKILL/assets/review-server.mjs" ack --all --token <token from that line> @@ -67,6 +67,25 @@ Offline remote comments: `bundle-artifact.mjs` — Send becomes copy (no session --- +## Round gate + +Claude Code is the only Host that can gate the end of a turn, so the plugin +ships a Stop hook at `plugins/vstack/hooks/hooks.json`. It runs +`review-server.mjs unanswered --all --session <session_id>` and blocks the turn +while a comment **this session** took delivery of is still unanswered, telling +you which one and which command settles it. Rules 14 and 16 of +[review-loop.md](../../../contracts/review-loop.md) are what it enforces. + +Delivery carries a session only when the watcher was started with +`--session "$CLAUDE_CODE_SESSION_ID"`, as the operation map says — a watcher +started without it leaves its deliveries unowned, and the gate does not hold +anyone for those. + +It blocks at most once per turn, and it stands aside whenever no review has a +server behind it. + +--- + ## Notes - Update detection uses Claude’s install record (`capabilities.updateDetect: claude-install`). diff --git a/plugins/vstack/skills/review/hosts/codex.md b/plugins/vstack/skills/review/hosts/codex.md index 89834f8..87b81d1 100644 --- a/plugins/vstack/skills/review/hosts/codex.md +++ b/plugins/vstack/skills/review/hosts/codex.md @@ -18,7 +18,7 @@ node "$SKILL/assets/review-server.mjs" serve --file "$FILE" --port 7788 --host c | `background(cmd)` | persistent shell execution (`exec_command`) | Start with a short yield and retain the returned session id. The review server must stay alive. | | `watch_stream(cmd)` | a second persistent `exec_command`, then `write_stdin` | Run `watch --all --stream`; poll the session with an empty write, normally for 30 seconds at a time, until it emits an event. Keep polling while reviews remain open. | | `stop(handle)` | `write_stdin` | Send Ctrl-C (`\u0003`) to the retained server or watcher session. | -| `run(cmd)` | foreground `exec_command` | Use for `publish`, `claim`, `reply`, `ack`, `check`, `share`, and `status`. | +| `run(cmd)` | foreground `exec_command` | Use for `publish`, `reply`, `ack`, `share`, `status`, and `unanswered`. | | `edit` | `apply_patch` | Change the wireframe or application source without overwriting unrelated work. | | `share(file)` | no generic public Artifact publisher | Profile uses `capabilities.share: copy`; offer the HTML file or an offline bundle instead of inventing a URL. | | `browser_capture` | Codex Browser controls, when installed | Navigate, resize, screenshot, and run `harvest-reference.js`. If Browser is unavailable, use screenshots supplied by the user. | @@ -54,7 +54,10 @@ normal persistent command session: 3. Answer the `HANDSHAKE` line the stream opens with, using `run`: `ack --all --token <token>`. The watcher goes live once you do; answer within two minutes. 4. On `REVIEW`, `REPLIED`, `SHARE`, `APPROVED`, or `CLOSED`, follow the core skill and review-loop contract. -5. Resume polling after each published round. Do not send the final response +5. Run `unanswered --all` before you end a turn, and settle whatever it names. + Codex cannot gate the end of a turn, so rule 14 of + [review-loop.md](../../../contracts/review-loop.md) is yours to keep. +6. Resume polling after each publish. Do not send the final response while the review is still active; keep the Codex turn open until approval, closure, or an explicit request from the user to stop. @@ -83,6 +86,14 @@ codex plugin add vstack@cavalry-collective Start a new Codex thread and invoke **`$vstack:review`**, or describe a wireframe or UI-review task and let the skill trigger implicitly. -Update detection is disabled for Codex (`updateDetect: none`); use -`codex plugin marketplace upgrade cavalry-collective` and reinstall the plugin -when testing a newer marketplace revision. +Update detection uses the version directory Codex unpacked this copy into +(`capabilities.updateDetect: codex-install`), so the workspace says when a newer +release exists. Update with the two commands the banner shows: + +```text +codex plugin marketplace upgrade cavalry-collective +codex plugin add vstack@cavalry-collective +``` + +A running Codex thread keeps the copy it started with. Start a new thread after +updating. diff --git a/plugins/vstack/skills/review/hosts/grok.md b/plugins/vstack/skills/review/hosts/grok.md index 961f814..f349caf 100644 --- a/plugins/vstack/skills/review/hosts/grok.md +++ b/plugins/vstack/skills/review/hosts/grok.md @@ -20,7 +20,7 @@ export VSTACK_HOST=grok | `background(cmd)` | `run_terminal_command` with `background: true` | `serve` must outlive the turn | | `watch_stream(cmd)` | **`monitor`** tool, `persistent: true` | `watch --all --stream` — each stdout line is a chat event | | `stop(handle)` | `kill_command_or_subagent` with the task id | After approve or when ending the review | -| `run(cmd)` | `run_terminal_command` (foreground) | `publish`, `claim`, `reply`, `ack`, `check`, `status` | +| `run(cmd)` | `run_terminal_command` (foreground) | `publish`, `reply`, `ack`, `status`, `unanswered` | | `edit` | file edit tools (`search_replace`, `write`, …) | HTML wireframe or app source | | `share(file)` | **Not available** as a public Artifact | Profile `capabilities.share: copy` — do not run the share-URL flow; UI hides “Publish a link” | | `browser_capture` | Browser MCP / chrome-devtools when connected | Otherwise use user screenshots per skill §2 | @@ -59,19 +59,14 @@ When `monitor` delivers a line: | Line prefix | Action (same as [review-loop.md](../../../contracts/review-loop.md)) | | --- | --- | | `HANDSHAKE` | Run the `ack` command it prints, immediately — the watcher goes live once you do | -| `REVIEW` | `claim` the round, read `feedback.md`, apply, `publish` / `reply` — never delete protocol files | -| `REPLIED` | Continue that comment’s thread | +| `REVIEW` | read the `brief.md` it names, apply it, then `publish --close` / `reply` — never delete protocol files | | `SHARE` | Host has no artifact share — tell the user to copy/export the HTML, or use `bundle-artifact.mjs` for a file they can send | | `APPROVED` | Confirm; offer next pipeline stage if applicable | | `CLOSED` | Note the review ended | -During a long round, `check` before publish: - -```bash -node "$SKILL/assets/review-server.mjs" check --file "$FILE" -``` - -It always exits 0. If it names a round waiting unclaimed, claim that round first. +Run `unanswered --all` before you end a turn, and settle whatever it names. +Grok cannot gate the end of a turn, so rule 14 of +[review-loop.md](../../../contracts/review-loop.md) is yours to keep. --- diff --git a/plugins/vstack/skills/review/references/workflow.md b/plugins/vstack/skills/review/references/workflow.md index 14e182f..884fab2 100644 --- a/plugins/vstack/skills/review/references/workflow.md +++ b/plugins/vstack/skills/review/references/workflow.md @@ -14,15 +14,12 @@ wireframes/ candidate-pipeline.html ← the page — the ONLY file you edit .vstack/local/review/ candidate-pipeline/ - state.json { name, version } + state.json { name, version, file? | app? } + comments.json every comment for this review — the whole truth + brief.md the open comments, rewritten on every delivery versions/v1.html frozen copy of each published version - versions/v1.meta.json label, date, which ids it answered - reviews/v1/ - annotations.json live workspace state (autosaved while reviewing) - feedback.md the brief you read - feedback.json the same, structured - rounds/r1.json durable membership, revisions and outcomes - pending notification — written on send, cleared by claim + versions/v1.meta.json label and date + reviews/v1/ only ever read — where an older version kept its comments handshake a stream watcher waiting to be told its events land approved sentinel — signed off; the review is over share sentinel — they want a shareable Artifact link @@ -38,27 +35,34 @@ A live review has no file to sit beside, so its store is `.vstack/local/review/<name>/` under the directory `serve` was run from, and `state.json` also carries the app's origin. `versions/v<n>.html` is then a capture of the screen the reviewer was -commenting on when they sent round *n* — the timeline scrubs to it, and it is -what `share` publishes. A round nobody sent a review from has no capture, which -the workspace says in the frame rather than showing an error. +commenting on when they sent — the timeline scrubs to it, and it is what `share` +publishes. A version nobody sent a comment from has no capture, which the +workspace says in the frame rather than showing an error. -`pending`, `approved` and `share` are the three ways the workspace reaches you. -Approving clears `pending`, and `serve` clears `approved` and `share` at startup. -Do not delete protocol files manually: `claim` clears `pending` and `share --url` -clears `share`. The round record remains as the validation and recovery ledger. +`comments.json`, `approved` and `share` are how the workspace reaches you. +`serve` clears `approved` and `share` at startup. Do not delete protocol files +manually: `share --url` clears `share`, and closing a comment is +`publish --close`. ## Commands ```bash # freeze the file as the next version and make it current node "$SKILL/assets/review-server.mjs" publish --file "$FILE" --label "Initial version" -node "$SKILL/assets/review-server.mjs" claim --file "$FILE" --round r1 + +# close what you did, and label the version you did it in — either flag alone is fine +node "$SKILL/assets/review-server.mjs" publish --file "$FILE" \ + --close c1f3k2,c9dk1 --label "Filters collapsed" + +# --summary adds the account you would give in chat; the workspace shows it on the +# banner when the round lands. The latest one is kept, and a publish without it clears it. node "$SKILL/assets/review-server.mjs" publish --file "$FILE" \ - --round r1 --label "Filters collapsed" --addressed c1f3k2,c9dk1 + --close c1f3k2 --label "Filters collapsed" \ + --summary "Filters are behind one control now. I left the date column alone." # ask about a comment instead of guessing — the question lands on the mark node "$SKILL/assets/review-server.mjs" reply --file "$FILE" \ - --round r1 --comment c7f2a1 --text "Every overdue row, or only the ones assigned to you?" + --comment c7f2a1 --text "Every overdue row, or only the ones assigned to you?" # serve (Host op background) — opens the workspace in the browser, closes itself 90s after the tab does # --host / VSTACK_HOST selects UI labels (claude | codex | grok); see contracts/host.md @@ -87,11 +91,9 @@ node "$SKILL/assets/review-server.mjs" serve --app http://localhost:5173 --name node "$SKILL/assets/review-server.mjs" serve --app :5173 --name lora-ui --start /workflows # every other command names the review instead of a file -node "$SKILL/assets/review-server.mjs" claim --name lora-ui --round r1 -node "$SKILL/assets/review-server.mjs" publish --name lora-ui --round r1 --label "Date column added" --addressed c1f3k2 -node "$SKILL/assets/review-server.mjs" reply --name lora-ui --round r1 --comment c7f2a1 --text "Created, or finished?" +node "$SKILL/assets/review-server.mjs" publish --name lora-ui --close c1f3k2 --label "Date column added" +node "$SKILL/assets/review-server.mjs" reply --name lora-ui --comment c7f2a1 --text "Created, or finished?" node "$SKILL/assets/review-server.mjs" status --name lora-ui -node "$SKILL/assets/review-server.mjs" check --name lora-ui ``` `--name` finds the store under the current directory — run every command from @@ -133,8 +135,7 @@ WATCHING 2 review(s): wireframe, spec-tree HANDSHAKE this stream is not live until you answer it. Run now: node …/review-server.mjs ack --all --token 7f3a91 LINKED handshake answered — the workspace says Linked from here -REVIEW wireframe · r17 · 3 comment(s) · …/reviews/v12/feedback.md -REPLIED wireframe · v12/c7h0zh0 · "let's align it to the bottom" +REVIEW wireframe · 3 open, 1 new · …/.vstack/local/review/wireframe/brief.md OPENED story-map-template · now watching 3 review(s) CLOSED spec-tree · the tab went away ``` @@ -151,13 +152,8 @@ A watcher that covers no review at all says `UNLINKED` in place of `LINKED` once its handshake is answered. Nothing is listening to any workspace at that point, whatever the handshake proved: start it again with `--file <page.html>`. -**Linked** also needs the rounds to move: a round left unclaimed for 90 seconds -flips the page back to **Unlinked** and marks the sent comments "not picked up -yet". If that happens while your watcher is running, its events are not reaching -you — check how it was started against the Host adapter, then claim the round. - -First thing after a `REVIEW`: run `claim --round <id>` using the id in the event. -It clears the notification but preserves the durable ledger. Use `share --url` +First thing after a `REVIEW`: read the `brief.md` it names. Delivery is already +recorded, so the workspace shows those comments as being worked on. Use `share --url` after publishing a link. A one-shot form (`watch` without `--stream`) still exists: it exits on the first @@ -173,14 +169,14 @@ exits `3`. **Arm exactly one waiter per review.** Re-arming without stopping the previous one leaves loops polling paths that no longer exist. -## Reading the feedback +## Reading the brief -`feedback.md` is a brief grouped by screen size; the fenced JSON block at the -bottom is the same data structured. Each comment: +`brief.md` is every open comment, grouped by screen size and rewritten on each +delivery. `comments.json` beside it is the same data structured. Each comment: | field | meaning | |---|---| -| `id` | pass back via `--addressed` once handled | +| `id` | pass back via `--close` once handled | | `kind` | `comment` (a point), `area` (a region), `general` (the page as a whole), `move` (an arrow), `strike` (something marked for removal) | | `note` | the reviewer's words — the actual requirement. **Every comment is a must**; there is no severity to triage by. Empty on a `move` or a `strike`, which say what they want by themselves | | `anchor` | the element the comment was made on: `tag`, `id`, `classes`, `role`, `text`, `label`, the `region` it sits in, and the `selector` that found it | @@ -239,23 +235,22 @@ the desktop one. ## The conversation -The reviewer has no resolve button — a validated -`publish --round <id> --addressed <ids>` is the only thing that closes a comment out. The command -fails without creating a version when any round member is left open, an id or -revision is stale, or the round was not claimed. -They can delete a comment or clear the lot, but they cannot mark one done. +The reviewer has no resolve button — `publish --close <ids>` is the only thing +that closes a comment out, and nothing they do can refuse it. Anything you do +not name stays open and comes back on the next delivery, so a partial answer is +a normal one. +They can take back a comment you have not been handed yet, but they cannot mark +one done. Emptying a comment's text deletes it, so an empty comment never reaches you. -What they *can* do is send one back. Addressed comments stay in their list under -their own heading with **Revert** and **Refine** beside them; either reopens the -comment and returns it next round, revert with a reply asking for the change to -be undone. That is the only path by which a status goes backwards — the server -refuses an un-address from a client unless the annotation carries the -`reopenedAt` stamp those two buttons write. +What they *can* do is answer. Closed comments stay in their list under their own +heading with **Revert** and **Refine** beside them; both write into the thread, +and a reviewer's reply on a closed comment reopens it. That is the only path by +which a comment goes backwards, and it is the server that applies it — a client +can never set a comment's state itself. -Send is one click with no preview, and greys out until a comment is added, -edited or answered — so a review landing on your waiter always contains -something new. +Send is one click with no preview: it lets go of what has been written, which +also freezes those words. Anything typed afterwards is a reply. When a comment is ambiguous, `reply` beats guessing. The question renders on the mark itself, the comment shows as *{agent} asked*, and their answer flips it back diff --git a/plugins/vstack/skills/review/tests/design-tokens.mjs b/plugins/vstack/skills/review/tests/design-tokens.mjs new file mode 100644 index 0000000..7e3822c --- /dev/null +++ b/plugins/vstack/skills/review/tests/design-tokens.mjs @@ -0,0 +1,147 @@ +/* + * The shell's palette is a copy of the design guide's, because a page has to + * work with no external request and so cannot import one. A copy drifts, and a + * drifted copy is two greys that were meant to be one. This asserts they agree. + * + * Only the roles the shell exposes are checked, and only their colours, radius + * and mono stack — the values a hand edit is most likely to change. Composite + * shadows are built from the guide's steps rather than aliased, so they are + * checked for containing those steps rather than for equality. + */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const REPO = path.resolve(HERE, '../../../../..') +const GUIDE = path.join(REPO, 'design/tokens.css') +const SHELL = path.join(REPO, 'plugins/vstack/lib/shell/tokens.css') + +/** Every `--name: value` in one block, in source order. */ +function declarations (css) { + const found = new Map() + for (const [, name, value] of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) { + found.set(name, value.replace(/\s+/g, ' ').trim()) + } + return found +} + +/** The body of a rule, by the selector that opens it. */ +function block (css, selector) { + const at = css.indexOf(selector) + assert.notEqual(at, -1, `${selector} is not in the stylesheet`) + const open = css.indexOf('{', at) + let depth = 0 + for (let i = open; i < css.length; i++) { + if (css[i] === '{') depth++ + else if (css[i] === '}' && --depth === 0) return css.slice(open + 1, i) + } + throw new Error(`${selector} is never closed`) +} + +/** A value with every var() followed through to something literal. + + Compared on meaning, not on spelling: the shell writes `rgba(23,19,32,.08)` + where the guide writes `rgba(23, 19, 32, 0.08)`, and neither is more correct + than the other. Both sides go through this, so dropping whitespace and the + leading zero cannot hide a real difference. */ +function resolve (value, scope) { + let out = value + for (let pass = 0; pass < 10 && out.includes('var('); pass++) { + out = out.replace(/var\((--[a-z0-9-]+)\)/g, (whole, name) => scope.get(name) ?? whole) + } + return out.replace(/\s+/g, '').replace(/(^|[^0-9])0\./g, '$1.').toLowerCase() +} + +const guideCss = fs.readFileSync(GUIDE, 'utf8') +const shellCss = fs.readFileSync(SHELL, 'utf8') + +/* The shell role each guide role is carried as. */ +const ROLES = { + '--paper': '--ground', + '--surface': '--background', + '--surface-2': '--muted', + '--ink': '--foreground', + '--ink-2': '--foreground-2', + '--ink-3': '--muted-foreground', + '--line': '--border', + '--line-2': '--border-strong', + '--brand': '--primary', + '--brand-soft': '--primary-subtle', + '--brand-line': '--primary-border', + '--ok': '--success', + '--ok-soft': '--success-subtle', +} + +/* Shape and type do not change with the theme, so they are stated once in the + default block rather than in each. */ +const CONSTANTS = { + '--radius': '--radius-2', + '--mono': '--font-family-mono', +} + +/* A rem radius in the guide is px in the shell — the shell is stamped into + pages whose root font size it does not control. */ +const asShellValue = (role, value) => + role === '--radius' ? `${parseFloat(value) * 16}px` : value + +const themes = [ + { name: 'light', guide: ':root {', shell: ':root[data-theme=light]{' }, + { name: 'dark', guide: ':root[data-theme="dark"] {', shell: ':root[data-theme=dark]{' }, +] + +let checked = 0 +for (const theme of themes) { + const guideScope = declarations(block(guideCss, theme.guide)) + // Dark restates the semantic tier only; primitives still come from :root. + const base = declarations(block(guideCss, ':root {')) + for (const [name, value] of base) if (!guideScope.has(name)) guideScope.set(name, value) + + const shellScope = declarations(block(shellCss, theme.shell)) + + for (const [shellRole, guideRole] of Object.entries(ROLES)) { + const want = asShellValue(shellRole, resolve(`var(${guideRole})`, guideScope)) + const got = resolve(shellScope.get(shellRole) ?? '', shellScope) + assert.ok(shellScope.has(shellRole), `the shell has no ${shellRole} in ${theme.name}`) + assert.equal(got, want, + `${theme.name} ${shellRole} is ${got}, but ${guideRole} in design/tokens.css is ${want}`) + checked++ + } + + /* Elevation is composed rather than aliased: one step for a raised edge, two + for a popped one, the top two for a window. */ + const shadow = step => resolve(`var(${step})`, guideScope) + assert.equal(resolve(shellScope.get('--shadow'), shellScope), shadow('--shadow-1'), + `${theme.name} --shadow is not the guide's raised step`) + for (const [role, steps] of [['--shadow-pop', ['--shadow-1', '--shadow-2']], + ['--window-shadow', ['--shadow-2', '--shadow-3']]]) { + const got = resolve(shellScope.get(role), shellScope) + for (const step of steps) { + assert.ok(got.includes(shadow(step)), + `${theme.name} ${role} does not carry ${step} (${shadow(step)})`) + } + checked++ + } +} + +/* The default :root and the explicit light choice must agree, or a page that + never sets data-theme looks different from one that chooses light. */ +const auto = declarations(block(shellCss, ':root{')) +const light = declarations(block(shellCss, ':root[data-theme=light]{')) +for (const role of Object.keys(ROLES)) { + assert.ok(light.has(role), `the light palette has no ${role}`) + assert.equal(auto.get(role), light.get(role), + `${role} differs between the default palette and the light one`) +} + +const guideRoot = declarations(block(guideCss, ':root {')) +for (const [shellRole, guideRole] of Object.entries(CONSTANTS)) { + const want = asShellValue(shellRole, resolve(`var(${guideRole})`, guideRoot)) + const got = resolve(auto.get(shellRole) ?? '', auto) + assert.equal(got, want, + `${shellRole} is ${got}, but ${guideRole} in design/tokens.css is ${want}`) + checked++ +} + +console.log(`design tokens: ok (${checked} roles across light and dark)`) diff --git a/plugins/vstack/skills/review/tests/host-profiles.mjs b/plugins/vstack/skills/review/tests/host-profiles.mjs index e0accdf..ebf0100 100644 --- a/plugins/vstack/skills/review/tests/host-profiles.mjs +++ b/plugins/vstack/skills/review/tests/host-profiles.mjs @@ -18,7 +18,7 @@ assert.deepEqual(codex.capabilities, { share: 'copy', watch: 'stream', browser: true, - updateDetect: 'none', + updateDetect: 'codex-install', }) assert.equal(resolveHostId({ host: ' CODEX ' }), 'codex') @@ -29,8 +29,8 @@ assert.match(html, /"name":"Codex"/) /* Every profile conforms to contracts/host.schema.json. loadHost checks only the top-level keys, so the schema's shape is enforced here — nowhere else - validates it, and an off-enum value would otherwise fail silently at - runtime (update-check gates on "none" alone). */ + validates it, and an off-enum updateDetect would otherwise silently show no + banner, which reads exactly like having nothing to report. */ for (const id of listHosts()) { const p = loadHost(id) const where = `host-profiles/${id}.json` @@ -45,12 +45,18 @@ for (const id of listHosts()) { assert.ok(['artifact', 'copy', 'none'].includes(c.share), `${where}: share enum`) assert.ok(['stream', 'oneshot'].includes(c.watch), `${where}: watch enum`) assert.equal(typeof c.browser, 'boolean', `${where}: browser`) - assert.ok(['claude-install', 'none'].includes(c.updateDetect), `${where}: updateDetect enum`) + assert.ok(['claude-install', 'codex-install', 'none'].includes(c.updateDetect), `${where}: updateDetect enum`) if (p.install) { assert.deepEqual(Object.keys(p.install).filter(k => !['howLead', 'commands', 'auto'].includes(k)), [], `${where}: unknown install keys`) assert.ok((p.install.commands || []).every(x => typeof x === 'string'), `${where}: install.commands`) } + /* A Host that detects an update has to be able to say how to take it. The + fallback wording in update-check.mjs is Claude Code's slash commands, and + printing those to anyone else is worse than saying nothing. */ + if (c.updateDetect !== 'none') { + assert.ok(p.install?.commands?.length, `${where}: updateDetect without install.commands`) + } } console.log('host profiles: ok') diff --git a/plugins/vstack/skills/review/tests/review-lifecycle.mjs b/plugins/vstack/skills/review/tests/review-lifecycle.mjs index 619f2b1..f81e14b 100644 --- a/plugins/vstack/skills/review/tests/review-lifecycle.mjs +++ b/plugins/vstack/skills/review/tests/review-lifecycle.mjs @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' const HERE = path.dirname(fileURLToPath(import.meta.url)) const SERVER = path.resolve(HERE, '../assets/review-server.mjs') -const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-round-test-')) +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-review-test-')) const page = path.join(temp, 'page.html') const store = path.join(temp, '.vstack', 'local', 'review', 'page') const port = 18000 + (process.pid % 1000) @@ -25,6 +25,11 @@ async function request (pathname, options) { return { response, body } } +const post = (pathname, body) => request(pathname, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), +}) + async function waitForServer () { for (let attempt = 0; attempt < 60; attempt++) { try { @@ -48,281 +53,201 @@ async function startServer () { assert.match(await workspace.text(), /window\.__VSTACK_HOST__=\{"id":"codex","name":"Codex"/) } +/** What the workspace saves. `sentAt` set means the reviewer let go of it. */ const comment = (id, note, extra = {}) => ({ - id, kind: 'area', status: 'open', note, size: 'desktop', replies: [], ...extra, + id, kind: 'area', note, size: 'desktop', replies: [], ...extra, }) +const write = comments => post('/api/comments', { comments }) +const send = comments => write(comments.map(item => ({ ...item, sentAt: new Date().toISOString() }))) -async function sendRound (version, comments) { - return request('/api/feedback', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - version, - annotations: comments, - feedback: { comments: comments.map(item => ({ ...item })) }, - counts: { total: comments.length }, - markdown: '# Test review\n\n<round-id>\n', - }), - }) -} +const stored = () => JSON.parse(fs.readFileSync(path.join(store, 'comments.json'))).comments +const byId = id => stored().find(item => item.id === id) +const briefText = () => fs.readFileSync(path.join(store, 'brief.md'), 'utf8') -let server, awayServer +/** One tick: block until there is something to hand over, take it, and exit. */ +const tick = () => spawnSync(process.execPath, [SERVER, 'watch', '--file', page], { + encoding: 'utf8', cwd: temp, timeout: 20_000, +}).stdout + +let server try { - fs.writeFileSync(page, '<!doctype html><title>Round test

Initial

') + fs.writeFileSync(page, 'Review test

Initial

') assert.equal(cli('publish', '--label', 'Initial').status, 0) - - await startServer() - - const first = await sendRound(1, [comment('c1', 'First'), comment('c2', 'Second')]) - assert.equal(first.response.status, 200) - assert.equal(first.body.roundId, 'r1') - - // "carry on" alone is how queued comments go unread: an unclaimed round is - // named on every check, and the exit code still says continue. - let result = cli('check') - assert.equal(result.status, 0, 'check always carries on') - assert.match(result.stdout, /r1 .* waiting unclaimed/) - assert.match(result.stdout, /claim .*--round r1/) - - // A fresh watcher heartbeat with the round still young reads as linked … - fs.writeFileSync(path.join(store, 'watching'), String(Date.now())) - let project = await request('/api/project') - assert.equal(project.body.watching, true) - assert.equal(project.body.activeReview.stalled, false) - - // … but past the claim window the heartbeat no longer counts: a watcher - // nobody reads and no watcher at all must look the same to the reviewer. - const roundFile = path.join(store, 'rounds', 'r1.json') - const backdated = JSON.parse(fs.readFileSync(roundFile)) - backdated.createdAt = new Date(Date.now() - 120_000).toISOString() - fs.writeFileSync(roundFile, JSON.stringify(backdated)) - project = await request('/api/project') - assert.equal(project.body.watching, false, 'a round unclaimed past the window must drop the linked state') - assert.equal(project.body.activeReview.stalled, true) - - result = cli('publish', '--round', 'r1', '--label', 'Too soon', '--addressed', 'c1,c2') - assert.equal(result.status, 2, 'an unclaimed round must not publish') - assert.match(result.stderr, /claim r1/i) - assert.equal(JSON.parse(fs.readFileSync(path.join(store, 'state.json'))).version, 1) - - assert.equal(cli('claim', '--round', 'r1').status, 0) - project = await request('/api/project') - assert.equal(project.body.watching, true, 'claiming the round restores the linked state') - assert.match(cli('check').stdout, /^carry on\s*$/, 'a claimed round needs no warning') - - /* A stream watcher asks for the one thing only a live session can do, because - nothing in the process can tell which tool started it. Presence begins when - the handshake is answered; unanswered, the watcher exits saying so, which on - hosts that re-invoke on exit delivers itself to whoever started it. */ - const watcher = (...extra) => { - const child = spawn(process.execPath, [SERVER, 'watch', '--file', page, '--stream', ...extra], { - cwd: temp, stdio: ['ignore', 'pipe', 'pipe'], - }) - let out = '' - child.stdout.on('data', chunk => { out += chunk }) - return { child, read: () => out, ended: new Promise(resolve => child.once('exit', resolve)) } - } - fs.rmSync(path.join(store, 'watching'), { force: true }) - const ignored = watcher('--handshake-timeout', '2') - assert.equal(await ignored.ended, 3, 'an unanswered watcher must exit non-zero') - assert.match(ignored.read(), /HANDSHAKE/) - assert.match(ignored.read(), /UNWIRED/) - assert.equal(fs.existsSync(path.join(store, 'watching')), false, - 'a watcher nobody answered must never claim presence') - - const wired = watcher('--handshake-timeout', '30') - for (let i = 0; i < 50 && !fs.existsSync(path.join(store, 'handshake')); i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - const token = JSON.parse(fs.readFileSync(path.join(store, 'handshake'), 'utf8')).token - assert.equal(cli('ack', '--token', 'wrong').status, 2, 'a wrong token must not answer the handshake') - assert.equal(cli('ack', '--token', token).status, 0) - for (let i = 0; i < 50 && !fs.existsSync(path.join(store, 'watching')); i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - assert.ok(fs.existsSync(path.join(store, 'watching')), 'an answered watcher starts beating') - assert.match(wired.read(), /LINKED/) - wired.child.kill('SIGTERM') - await wired.ended - assert.equal(cli('ack', '--token', token).status, 0, 'answering twice is not an error') - - /* Starting a second watcher overwrites the first one's handshake, so an - answer names which one it is for. A watcher that read a missing handshake - as its own answer would go live on someone else's — and keep beating after - the answered one stopped, which is presence claiming exactly what it cannot - see. */ - fs.rmSync(path.join(store, 'watching'), { force: true }) - const ignoredWatcher = watcher('--handshake-timeout', '4') - for (let i = 0; i < 50 && !fs.existsSync(path.join(store, 'handshake')); i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - const firstToken = JSON.parse(fs.readFileSync(path.join(store, 'handshake'), 'utf8')).token - const answeredWatcher = watcher('--handshake-timeout', '30') - for (let i = 0; i < 50 && JSON.parse(fs.readFileSync(path.join(store, 'handshake'), 'utf8')).token === firstToken; i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - const secondToken = JSON.parse(fs.readFileSync(path.join(store, 'handshake'), 'utf8')).token - assert.notEqual(secondToken, firstToken, 'the second watcher asks in its own name') - assert.equal(cli('ack', '--token', secondToken).status, 0) - assert.equal(await ignoredWatcher.ended, 3, 'a watcher answered in another name must still time out') - assert.match(ignoredWatcher.read(), /UNWIRED/) - assert.doesNotMatch(ignoredWatcher.read(), /LINKED/, 'only the watcher that was answered may claim presence') - assert.match(answeredWatcher.read(), /LINKED/) - assert.ok(fs.existsSync(path.join(store, 'watching')), - 'the answered watcher keeps beating through the other one exiting') - answeredWatcher.child.kill('SIGTERM') - await answeredWatcher.ended - - /* A page an agent generates lands in a temp directory, and its store lands - beside it — outside the directory the session runs `watch --all` from. The - walk cannot reach it, so the serve leaves a pointer in the directory both - processes do share, and the watcher heartbeats the review it names. Without - that, the handshake is answered, the stream says Linked, and the workspace - sits on Unlinked with nobody able to see why. */ - const away = fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-away-test-')) - const awayPage = path.join(away, 'elsewhere.html') - const awayStore = path.join(away, '.vstack', 'local', 'review', 'elsewhere') - fs.writeFileSync(awayPage, 'Elsewhere

Away

') - awayServer = spawn(process.execPath, [SERVER, 'serve', '--file', awayPage, - '--port', String(port + 1), '--idle-timeout', '0', '--no-open'], { cwd: temp, stdio: 'ignore' }) - for (let i = 0; i < 60 && !fs.existsSync(path.join(awayStore, 'url')); i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - assert.ok(fs.existsSync(path.join(awayStore, 'url')), 'the second review must come up') - - const everywhere = spawn(process.execPath, [SERVER, 'watch', '--all', '--stream', '--handshake-timeout', '30'], - { cwd: temp, stdio: ['ignore', 'pipe', 'pipe'] }) - let heard = '' - everywhere.stdout.on('data', chunk => { heard += chunk }) - const allHandshake = path.join(temp, '.vstack', 'local', 'review', 'handshake') - for (let i = 0; i < 50 && !fs.existsSync(allHandshake); i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - assert.equal(spawnSync(process.execPath, [SERVER, 'ack', '--all', - '--token', JSON.parse(fs.readFileSync(allHandshake, 'utf8')).token], { cwd: temp, encoding: 'utf8' }).status, 0) - for (let i = 0; i < 50 && !fs.existsSync(path.join(awayStore, 'watching')); i++) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - assert.ok(fs.existsSync(path.join(awayStore, 'watching')), - 'a review outside the watcher\'s directory must still be heartbeaten') - assert.match(heard, /LINKED/) - everywhere.kill('SIGTERM') - await new Promise(resolve => everywhere.once('exit', resolve)) - - const pointer = path.join(temp, '.vstack', 'local', 'review', '.serving') - assert.equal(fs.readdirSync(pointer).length, 2, 'each live serve points at its own store') - awayServer.kill('SIGTERM') - await new Promise(resolve => awayServer.once('exit', resolve)) - awayServer = null - assert.equal(fs.readdirSync(pointer).length, 1, 'a serve that ends takes its pointer with it') - fs.rmSync(away, { recursive: true, force: true }) - - fs.writeFileSync(path.join(store, 'watching'), String(Date.now())) - result = cli('publish', '--round', 'r1', '--label', 'Incomplete', '--addressed', 'c1') - assert.equal(result.status, 2, 'an unresolved comment must block publication') - assert.match(result.stderr, /c2 is still open/) - - result = cli('publish', '--round', 'r1', '--label', 'Unknown', '--addressed', 'c1,c2,c999') - assert.equal(result.status, 2, 'unknown ids must block publication') - assert.match(result.stderr, /c999 does not belong/) - - await request('/api/annotations', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ version: 1, annotations: [comment('c1', 'First, edited'), comment('c2', 'Second')] }), - }) - result = cli('publish', '--round', 'r1', '--label', 'Stale', '--addressed', 'c1,c2') - assert.equal(result.status, 2, 'a stale comment revision must block publication') - assert.match(result.stderr, /changed after r1/) - - const refreshed = await sendRound(1, [comment('c1', 'First, edited'), comment('c2', 'Second')]) - assert.equal(refreshed.body.roundId, 'r1') - assert.equal(cli('claim', '--round', 'r1').status, 0) - assert.equal(cli('reply', '--round', 'r1', '--comment', 'c2', '--text', 'Could you clarify?').status, 0) - assert.equal(cli('publish', '--round', 'r1', '--label', 'First addressed', '--addressed', 'c1').status, 0) - - let state = JSON.parse(fs.readFileSync(path.join(store, 'state.json'))) - assert.equal(state.version, 2) - assert.equal(state.activeRound, undefined) - const saved = JSON.parse(fs.readFileSync(path.join(store, 'reviews', 'v1', 'annotations.json'))) - assert.equal(saved.annotations.find(item => item.id === 'c1').status, 'addressed') - assert.equal(saved.annotations.find(item => item.id === 'c2').status, 'question') - - assert.equal(cli('publish', '--round', 'r1', '--label', 'Retry', '--addressed', 'c1').status, 0) - state = JSON.parse(fs.readFileSync(path.join(store, 'state.json'))) - assert.equal(state.version, 2, 'retrying a completed round must be idempotent') - - const cleared = await request('/api/history/clear', { method: 'POST' }) - assert.equal(cleared.response.status, 200) - const afterClear = await request('/api/project') - assert.deepEqual(afterClear.body.versions.map(version => version.n), [2]) - assert.equal(afterClear.body.reviews[1].annotations.find(item => item.id === 'c2').status, 'question', - 'clearing snapshots must not remove comment history') - - const second = await sendRound(2, [comment('c2', 'Second', { - replies: [{ by: 'agent', text: 'Could you clarify?', at: '2026-01-01T00:00:00.000Z' }, - { by: 'reviewer', text: 'Yes, both.', at: '2026-01-01T00:01:00.000Z' }], - })]) - assert.equal(second.body.roundId, 'r2') - assert.equal(cli('claim', '--round', 'r2').status, 0) - // A restart is recovery, not a new review: the claimed round has to survive it. - server.kill('SIGTERM') - await new Promise(resolve => server.once('exit', resolve)) await startServer() - const recovered = await request('/api/project') - assert.equal(recovered.body.activeReview.id, 'r2', 'an active round must survive a restart') - assert.equal(recovered.body.activeReview.status, 'active') - let approval = await request('/api/approve', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ version: 2, expectedOpenCount: 0 }), + /* ── a comment is the reviewer's until they let go of it ── */ + + await write([comment('c1', 'First draft')]) + assert.equal(byId('c1').sentAt, null, 'a comment being written has not been sent') + assert.equal(byId('c1').state, 'open') + + await write([comment('c1', 'First, reworded')]) + assert.equal(byId('c1').note, 'First, reworded', "a draft is still the reviewer's to rewrite") + + const project = await request('/api/project') + assert.equal(project.body.comments.length, 1) + assert.equal(project.body.comments[0].deliveredAt, null, 'nothing is delivered until a tick takes it') + + await send([comment('c1', 'First, reworded'), comment('c2', 'Second')]) + assert.ok(byId('c1').sentAt, 'sending stamps the comment') + + /* ── sent means frozen ── */ + + await write([comment('c1', 'Something else entirely', { sentAt: byId('c1').sentAt })]) + assert.equal(byId('c1').note, 'First, reworded', + 'a comment already sent cannot be reworded, whatever a stale tab saves') + + /* ── the tick hands over everything open ── */ + + let out = tick() + assert.match(out, /REVIEW/) + assert.match(out, /2 open, 2 new/) + assert.match(briefText(), /### c1 · NEW/) + assert.match(briefText(), /First, reworded/) + assert.match(briefText(), /--close /) + assert.ok(byId('c1').deliveredAt, 'delivery is recorded on the comment') + + /* ── closing is the agent's alone, and partial by design ── */ + + const published = cli('publish', '--close', 'c1', '--label', 'Reworded heading') + assert.equal(published.status, 0) + assert.equal(byId('c1').state, 'closed') + assert.equal(byId('c2').state, 'open', 'a comment nobody named is still open') + assert.equal(JSON.parse(fs.readFileSync(path.join(store, 'state.json'))).version, 2) + // The tick wakes for what the reviewer says, so a comment left open has to be + // named where the agent believes it has finished. + assert.match(published.stdout, /1 comment\(s\) you were given are still open: c2/) + + assert.equal(cli('publish', '--close', 'c1').status, 0, 'closing what is closed is a no-op') + assert.equal(JSON.parse(fs.readFileSync(path.join(store, 'state.json'))).version, 2, + 'closing without a label adds no version') + + /* ── a reply on a closed comment is how the reviewer reopens it ── */ + + await write([{ ...byId('c1'), replies: [{ by: 'reviewer', text: 'Not like that', at: new Date().toISOString() }] }]) + assert.equal(byId('c1').state, 'open', 'answering something called done says it is not done') + out = tick() + assert.match(out, /2 open/, 'everything open goes, not only what was just said') + assert.doesNotMatch(out, /new/, 'a comment coming round again is not new') + assert.match(briefText(), /They replied:\*\* Not like that/) + assert.match(briefText(), /### c2/) + + /* ── a stranded round goes back to the queue ── */ + + const watching = path.join(store, 'watching') + fs.writeFileSync(watching, String(Date.now())) + const held = await post('/api/comments/requeue', {}) + assert.equal(held.response.status, 409, 'nothing is taken off an agent that is listening') + assert.ok(byId('c1').deliveredAt, 'and the handover stands') + + /* Nothing listening: both comments are delivered and neither is unseen, so no + watcher started after this point would ever be handed them. */ + const note = byId('c1').note + fs.rmSync(watching, { force: true }) + const requeued = await post('/api/comments/requeue', {}) + assert.equal(requeued.response.status, 200) + assert.deepEqual(requeued.body.requeued.sort(), ['c1', 'c2']) + assert.equal(byId('c1').deliveredAt, null, 'only the record of the handover goes') + assert.equal(byId('c1').state, 'open', 'the comment itself is untouched') + assert.equal(byId('c1').note, note, 'and it still says what it said') + + out = tick() + assert.match(out, /REVIEW/, 'the next session to pick up is handed the stranded round') + // New to the session receiving them, which is the whole point of putting them + // back: the one that was given them first is gone. + assert.match(out, /2 open, 2 new/) + + /* ── the thread is append-only, from either side ── */ + + assert.equal(cli('reply', '--comment', 'c2', '--text', 'Which card?').status, 0) + assert.equal(byId('c2').state, 'open', 'asking is not a state — the comment stays open') + // A tab that never saw the agent's question saves its own copy of the thread. + await write([{ + ...comment('c2', 'Second', { sentAt: byId('c2').sentAt }), + replies: [{ by: 'reviewer', text: 'The second one', at: new Date().toISOString() }], + }]) + assert.deepEqual(byId('c2').replies.map(reply => reply.by), ['agent', 'reviewer'], + 'neither side can lose a line of the thread by being stale') + + /* ── liveness: whatever the reviewer did meanwhile, the agent can finish ── */ + + tick() + assert.equal(cli('publish', '--close', 'c1,c2', '--label', 'Both done').status, 0, + 'nothing the reviewer does can stop the agent closing what it was given') + assert.deepEqual(stored().map(item => item.state), ['closed', 'closed']) + + /* ── withdrawal, before and after delivery ── */ + + await write([comment('c3', 'Third')]) + let dismissed = await post('/api/comments/dismiss', { id: 'c3' }) + assert.equal(dismissed.response.status, 200, "a draft is the reviewer's to take back") + assert.equal(stored().find(item => item.id === 'c3'), undefined) + + await send([comment('c4', 'Fourth')]) + dismissed = await post('/api/comments/dismiss', { id: 'c4' }) + assert.equal(dismissed.response.status, 200, 'queued is still only waiting here') + + await send([comment('c5', 'Fifth')]) + tick() + dismissed = await post('/api/comments/dismiss', { id: 'c5' }) + assert.equal(dismissed.response.status, 200, 'a comment is the reviewer\'s to take off the list') + assert.ok(byId('c5').dismissedAt, 'one already delivered keeps its record') + assert.equal(byId('c5').state, 'closed', 'and nothing raises it again') + const gone = await request('/api/project') + assert.equal(gone.body.comments.find(item => item.id === 'c5'), undefined, + 'the workspace never shows it again') + assert.equal(cli('publish', '--close', 'c5').status, 0, + 'the agent holding it can still close what it was given') + + /* ── the agent cannot close what it was never given ── */ + + await write([comment('c6', 'Sixth, still being written')]) + const early = cli('publish', '--close', 'c6') + assert.equal(early.status, 2) + assert.match(early.stderr, /c6 has not been sent yet/) + await send([comment('c7', 'Seventh')]) + const unknown = cli('publish', '--close', 'c7,c404') + assert.equal(unknown.status, 2) + assert.match(unknown.stderr, /c404 is not a comment on this review/) + assert.equal(byId('c7').state, 'open', 'a rejected close changes nothing at all') + + /* ── a store written by an older version is read where it lies ── */ + + const old = path.join(temp, 'old') + const oldStore = path.join(old, '.vstack', 'local', 'review', 'legacy') + fs.mkdirSync(path.join(oldStore, 'reviews', 'v1'), { recursive: true }) + fs.mkdirSync(path.join(oldStore, 'reviews', 'v2'), { recursive: true }) + fs.writeFileSync(path.join(old, 'legacy.html'), 'Legacy

x

') + fs.writeFileSync(path.join(oldStore, 'state.json'), JSON.stringify({ version: 2, name: 'legacy' })) + fs.writeFileSync(path.join(oldStore, 'reviews', 'v1', 'annotations.json'), JSON.stringify({ + annotations: [ + { id: 'a1', note: 'Done back then', status: 'addressed', sentAt: '2026-01-01T00:00:00.000Z' }, + { id: 'a2', note: 'Withdrawn', dismissed: true }, + { id: 'a3', note: 'Stale copy', status: 'open', sentAt: '2026-01-01T00:00:00.000Z' }, + ], + })) + fs.writeFileSync(path.join(oldStore, 'reviews', 'v2', 'annotations.json'), JSON.stringify({ + annotations: [ + { + id: 'a3', note: 'Still open', status: 'question', sentAt: '2026-01-02T00:00:00.000Z', + replies: [{ by: 'claude', text: 'Which one?', at: '2026-01-02T00:00:00.000Z' }], + }, + ], + })) + const legacy = spawnSync(process.execPath, [SERVER, 'status', '--file', path.join(old, 'legacy.html')], { + encoding: 'utf8', cwd: old, }) - assert.equal(approval.response.status, 409, 'approval must reject a stale client count') - assert.equal(approval.body.openComments.length, 1) - - approval = await request('/api/approve', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ version: 2, expectedOpenCount: 1 }), - }) - assert.equal(approval.response.status, 200) - const approved = JSON.parse(fs.readFileSync(path.join(store, 'approved'))) - assert.deepEqual(approved.openComments.map(item => item.id), ['c2']) - - const annotationsIn = version => - JSON.parse(fs.readFileSync(path.join(store, 'reviews', `v${version}`, 'annotations.json'))).annotations - - // A save reports what one client holds, which is never the whole review: a - // comment carried from an earlier version is not in the payload at all. An id - // the client left out must survive the save that omitted it. - const save = annotations => request('/api/annotations', { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ version: 2, annotations }), - }) - await save([comment('c3', 'Third'), comment('c4', 'Fourth')]) - await save([comment('c3', 'Third, edited')]) - assert.deepEqual(annotationsIn(2).map(item => item.id).sort(), ['c2', 'c3', 'c4'], - 'a save must not delete the comments it did not mention') - assert.equal(annotationsIn(2).find(item => item.id === 'c3').note, 'Third, edited', - 'a save must still update the comments it did mention') - - // c1 was addressed back on v1 and has not been touched since, so it lives - // only in that older review file. The reply belongs where the workspace is - // looking — the current version — not where the comment happens to sit. - const replied = cli('reply', '--comment', 'c1', '--text', 'Which heading did you mean?') - assert.equal(replied.status, 0) - assert.match(replied.stdout, /on v2 \(carried forward from v1\)/) - const answered = annotationsIn(2).find(item => item.id === 'c1') - assert.ok(answered, 'a reply must land in the version the workspace has open') - assert.equal(answered.replies.at(-1).text, 'Which heading did you mean?') - assert.equal(answered.status, 'question') - assert.deepEqual(annotationsIn(1).find(item => item.id === 'c1').replies, [], - 'the stale copy must not be the one that changed') - - assert.equal(cli('reply', '--comment', 'c404', '--text', 'Nobody home').status, 1, - 'a comment in no version at all must fail loudly') - + assert.equal(legacy.status, 0) + const adopted = JSON.parse(legacy.stdout).comments + assert.deepEqual(adopted.map(item => [item.id, item.state]).sort(), + [['a1', 'closed'], ['a2', 'closed'], ['a3', 'open']], + 'addressed and withdrawn are both closed, and the newest copy of an id wins') + assert.equal(adopted.find(item => item.id === 'a3').note, 'Still open') + assert.ok(fs.existsSync(path.join(oldStore, 'reviews', 'v1', 'annotations.json')), + 'the older store is read where it lies, never moved') console.log('review lifecycle integration: ok') } finally { server?.kill('SIGTERM') - awayServer?.kill('SIGTERM') fs.rmSync(temp, { recursive: true, force: true }) } diff --git a/plugins/vstack/skills/review/tests/round-gate.mjs b/plugins/vstack/skills/review/tests/round-gate.mjs new file mode 100644 index 0000000..0c19e1d --- /dev/null +++ b/plugins/vstack/skills/review/tests/round-gate.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/* + * What `unanswered` calls an unfinished round, and what the Stop hook does with it. + * + * The rule under test: a comment the agent took delivery of is answered by + * closing it or by replying to it, and until one of those happens the round has + * not been handed back. + */ + +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const SERVER = path.resolve(HERE, '../assets/review-server.mjs') +const GATE = path.resolve(HERE, '../../../hooks/round-gate.mjs') +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-gate-test-')) +const page = path.join(temp, 'page.html') +const port = 19000 + (process.pid % 1000) +const origin = `http://127.0.0.1:${port}` +let server = null + +const cli = (...argv) => spawnSync(process.execPath, [SERVER, ...argv], { + encoding: 'utf8', cwd: temp, timeout: 20_000, +}) + +/** The gate as Claude Code runs it: the Stop payload on stdin, a decision out. */ +const gate = (input = {}) => { + const run = spawnSync(process.execPath, [GATE], { + encoding: 'utf8', cwd: temp, timeout: 20_000, + input: JSON.stringify({ hook_event_name: 'Stop', cwd: temp, ...input }), + }) + assert.equal(run.status, 0, `the hook itself must never fail: ${run.stderr}`) + return run.stdout.trim() ? JSON.parse(run.stdout) : null +} + +const post = (pathname, body) => fetch(origin + pathname, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), +}) + +const comment = (id, note, extra = {}) => ({ + id, kind: 'area', note, size: 'desktop', replies: [], + sentAt: new Date().toISOString(), ...extra, +}) + +/** One tick: block until there is something to hand over, take it, and exit. */ +const tick = () => cli('watch', '--file', page) + +async function waitForServer () { + for (let attempt = 0; attempt < 60; attempt++) { + try { if ((await fetch(origin + '/api/project')).ok) return } catch {} + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error('review server did not start') +} + +try { + fs.writeFileSync(page, 'Page

Hi

') + server = spawn(process.execPath, [SERVER, 'serve', '--file', page, '--port', String(port), + '--idle-timeout', '0', '--no-open'], { cwd: temp, stdio: ['ignore', 'pipe', 'pipe'] }) + await waitForServer() + + // Nothing has been written, so there is nothing to owe. + assert.equal(cli('unanswered', '--all').status, 0, 'a review with no comments is finished') + assert.equal(gate(), null, 'the gate lets a quiet turn end') + + // Queued but not delivered: the agent has not been handed it, so it owes + // nothing yet. The watcher is what hands it over. + await post('/api/comments', { comments: [comment('c1', 'Make the hero bigger')] }) + assert.equal(cli('unanswered', '--all').status, 0, 'a queued comment is not the agent\'s to answer') + + // Delivered and untouched — the round stopped halfway. + assert.match(tick().stdout, /REVIEW/, 'the tick hands the comment over') + const owed = cli('unanswered', '--all') + assert.equal(owed.status, 1, 'a delivered comment with nothing said about it is outstanding') + assert.match(owed.stdout, /c1/, 'it names the comment') + assert.match(owed.stdout, /publish .* --close c1/, 'it names the command that settles it') + assert.equal(owed.stdout.match(/you took delivery of/g).length, 1, + 'one review is reported once, however many ways its store is reached') + + const blocked = gate() + assert.equal(blocked?.decision, 'block', 'the gate holds the turn open') + assert.match(blocked.reason, /c1/, 'the agent is told which comment it left') + + // Having already blocked once this turn, the gate stands aside. + assert.equal(gate({ stop_hook_active: true }), null, 'the gate blocks at most once per turn') + + // Replying answers it without closing it: the agent asked a question, which + // is a legitimate way to end a round. + assert.equal(cli('reply', '--file', page, '--comment', 'c1', '--text', 'How much bigger?').status, 0) + assert.equal(cli('unanswered', '--all').status, 0, 'a reply hands the round back') + + // The reviewer writes about something else while c1 waits on them. Every tick + // re-stamps delivery on every open comment, so c1 is handed over again — but + // the agent has had its say on it and owes only the comment it has not. + await post('/api/comments', { comments: [comment('c3', 'And centre the footer')] }) + assert.match(tick().stdout, /REVIEW/, 'the new comment is handed over') + const alongside = cli('unanswered', '--all') + assert.equal(alongside.status, 1, 'the comment nothing has been said about is outstanding') + assert.match(alongside.stdout, /c3/, 'it names that comment') + assert.doesNotMatch(alongside.stdout, /c1/, + 'being handed a comment again does not unanswer the reply already on it') + assert.equal(cli('publish', '--file', page, '--close', 'c3', '--label', 'Footer centred').status, 0) + + // The reviewer answers. That comment is waiting for the next tick, not for + // the agent, so it must not hold the turn open. + const answered = { by: 'reviewer', text: 'Twice', at: new Date().toISOString() } + await post('/api/comments', { comments: [comment('c1', 'Make the hero bigger', { replies: [answered] })] }) + assert.equal(cli('unanswered', '--all').status, 0, 'a comment awaiting delivery is not outstanding') + + // Delivered again, and now unanswered again. + assert.match(tick().stdout, /REVIEW/, 'the answer comes back round') + assert.equal(cli('unanswered', '--all').status, 1, 'the agent owes an answer once more') + + // Closing it finishes the round. + assert.equal(cli('publish', '--file', page, '--close', 'c1', '--label', 'Hero doubled').status, 0) + assert.equal(cli('unanswered', '--all').status, 0, 'closing hands the round back') + assert.equal(gate(), null, 'the gate lets the turn end') + + // The reviewer takes a delivered comment off the list. It is no longer on the + // review, so there is nothing left to answer and the turn can end. + await post('/api/comments', { comments: [comment('cx', 'Never mind this one')] }) + assert.match(tick().stdout, /REVIEW/, 'it is handed over first') + assert.equal(cli('unanswered', '--all').status, 1, 'and it is outstanding while it stands') + await post('/api/comments/dismiss', { id: 'cx' }) + assert.equal(cli('unanswered', '--all').status, 0, 'a comment taken off the list owes nothing') + assert.equal(gate(), null, 'the gate lets the turn end') + + // A delivery binds to the session whose watcher took it, and the gate holds + // that session only. A session that never saw the round walks free — and is + // not handed the ids and commands that would close someone else's round. + await post('/api/comments', { comments: [comment('c4', 'Tighten the nav')] }) + assert.match(cli('watch', '--file', page, '--session', 'sess-a').stdout, /REVIEW/, + 'the tick hands the comment to sess-a') + assert.equal(gate({ session_id: 'sess-a' })?.decision, 'block', 'the session that took delivery is held') + assert.equal(gate({ session_id: 'sess-b' }), null, 'a session that never took delivery is not') + assert.equal(cli('unanswered', '--all').status, 1, + 'asked without an identity, the round still shows — a person asking means the review, not a session') + assert.equal(cli('publish', '--file', page, '--close', 'c4', '--label', 'Nav tightened').status, 0) + assert.equal(gate({ session_id: 'sess-a' }), null, 'closing frees the owner too') + + // The sweep never covers a review whose heartbeat is fresh: that is another + // session's watcher, and covering it twice would deliver one comment to two + // sessions. Once the heartbeat is gone the review is anyone's to take, and + // the new delivery re-binds the round to whoever took it. + const store = JSON.parse(cli('status', '--file', page).stdout).store + await post('/api/comments', { comments: [comment('c5', 'Widen the gutter')] }) + fs.writeFileSync(path.join(store, 'watching'), String(Date.now())) + assert.match(cli('watch', '--all').stdout, /nothing to watch/, 'a covered review is not covered twice') + fs.rmSync(path.join(store, 'watching')) + assert.match(cli('watch', '--all', '--session', 'sess-b').stdout, /REVIEW/, 'unclaimed, the sweep takes it') + assert.equal(gate({ session_id: 'sess-b' })?.decision, 'block', 'delivery bound the round to the sweeper') + assert.equal(gate({ session_id: 'sess-a' }), null, 'and to no one else') + assert.equal(cli('publish', '--file', page, '--close', 'c5', '--label', 'Gutter widened').status, 0) + + // A review nobody is looking at is over. `--all` reads live stores only, so + // the gate cannot strand a session on a round whose tab has gone. + await post('/api/comments', { comments: [comment('c2', 'And centre it')] }) + assert.match(tick().stdout, /REVIEW/, 'the second comment is handed over') + assert.equal(cli('unanswered', '--all').status, 1, 'outstanding while the review is live') + server.kill('SIGTERM') + for (let attempt = 0; attempt < 60 && server.exitCode === null; attempt++) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + assert.equal(cli('unanswered', '--all').status, 0, 'a closed review owes nothing') + assert.equal(cli('unanswered', '--file', page).status, 1, + 'the named form still reports it, because a person asking about one review means it') + + console.log('round gate: ok') +} finally { + server?.kill('SIGTERM') + fs.rmSync(temp, { recursive: true, force: true }) +} diff --git a/plugins/vstack/skills/review/tests/update-check.mjs b/plugins/vstack/skills/review/tests/update-check.mjs new file mode 100644 index 0000000..ce244c1 --- /dev/null +++ b/plugins/vstack/skills/review/tests/update-check.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/* + * update-check: a Host that says it can detect an update actually gets one. + * + * Every way this feature fails is silence — the wrong profile flag, an install + * this copy cannot recognise, a clone mistaken for an install — and silence is + * also what "you are up to date" looks like. So the banner is observed here + * rather than reasoned about. + * + * No network: the answer cache is seeded ahead of the call, so `ask` is inside + * its TTL and never reaches GitHub. + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const PLUGIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../') +const profile = id => JSON.parse(fs.readFileSync(path.join(PLUGIN, 'host-profiles', `${id}.json`), 'utf8')) +const VERSION = JSON.parse(fs.readFileSync(path.join(PLUGIN, '.claude-plugin/plugin.json'), 'utf8')).version +const LATEST = '99.0.0' + +const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'vstack-update-')) +/* Read at import time, so both must be in place before anything is loaded. */ +process.env.HOME = path.join(sandbox, 'home') +process.env.TMPDIR = path.join(sandbox, 'tmp') +fs.mkdirSync(process.env.TMPDIR, { recursive: true }) +delete process.env.VSTACK_NO_UPDATE_CHECK + +/* Where `codex plugin add` puts a release: one directory per version, which is + the only record Codex keeps of what it installed. */ +const installed = path.join(process.env.HOME, '.codex/plugins/cache/cavalry-collective/vstack', VERSION) +fs.mkdirSync(installed, { recursive: true }) +for (const part of ['lib', 'host-profiles', '.claude-plugin']) { + fs.cpSync(path.join(PLUGIN, part), path.join(installed, part), { recursive: true }) +} + +const cache = path.join(process.env.TMPDIR, 'vstack-update-check.json') +const seed = extra => fs.writeFileSync(cache, JSON.stringify({ at: Date.now(), kind: 'version', value: LATEST, ...extra })) +seed({}) + +const asCodexInstall = await import(pathToFileURL(path.join(installed, 'lib/update-check.mjs')).href) + +// The run that first hears about a release keeps it to itself; the next one says so. +assert.equal(await asCodexInstall.checkForUpdate(profile('codex')), null, 'first sighting is held back') + +const banner = await asCodexInstall.checkForUpdate(profile('codex')) +assert.ok(banner, 'Codex install is offered the update') +assert.equal(banner.key, LATEST) +assert.equal(banner.pill, 'update') +/* The commands are the Codex ones, not the `/plugin` fallback: printing Claude + Code slash commands to a Codex user is worse than printing nothing. */ +assert.deepEqual(banner.install, [ + 'codex plugin marketplace upgrade cavalry-collective', + 'codex plugin add vstack@cavalry-collective', +]) +assert.ok(!banner.install.some(line => line.startsWith('/')), 'no slash commands in a Codex banner') +assert.equal(banner.auto, null) + +// "Not now" is remembered for that release, and survives the ephemeral port. +asCodexInstall.dismissUpdate(LATEST) +assert.equal(await asCodexInstall.checkForUpdate(profile('codex')), null, 'a dismissed release stops asking') + +seed({ met: LATEST }) +// A Host with nothing to compare against is never asked the question. +assert.equal(await asCodexInstall.checkForUpdate(profile('grok')), null, 'updateDetect none stays quiet') + +/* This clone is not an install under any profile, so working on the plugin + never produces a banner about the branch in front of you. */ +const asClone = await import(pathToFileURL(path.join(PLUGIN, 'lib/update-check.mjs')).href) +assert.equal(await asClone.checkForUpdate(profile('codex')), null, 'a clone is not a Codex install') +assert.equal(await asClone.checkForUpdate(profile('claude')), null, 'a clone is not a Claude install') + +fs.rmSync(sandbox, { recursive: true, force: true }) +console.log('update check: ok') diff --git a/plugins/vstack/skills/user-story-map/assets/story-map-template.html b/plugins/vstack/skills/user-story-map/assets/story-map-template.html index ec7616f..9e3b089 100644 --- a/plugins/vstack/skills/user-story-map/assets/story-map-template.html +++ b/plugins/vstack/skills/user-story-map/assets/story-map-template.html @@ -25,58 +25,67 @@ /* One palette for every vstack page. Roles, not colours: a page asks for --surface, not for white, so light and dark are the same stylesheet. + The values come from `design/tokens.css`, which owns the palette. They are + copied rather than imported because a page has to work opened off disk and + inlined into an Artifact under a CSP that blocks every external request — + nothing here may be fetched. `tests/design-tokens.mjs` fails when the two + files disagree, so the copy cannot drift quietly. + Page-specific hues (the story map's phase bands, the board's new/have/touch, the spec's priorities) stay in the page, below this block — they mean something only there. Everything here is shared, and is the reason a board and a spec look like the same product. Three states, in this order: the OS preference, then an explicit choice. - `data-theme` absent means auto. */ + `data-theme` absent means auto. + + The type scale is the guide's; the families are not. Space Grotesk and Inter + would each be an external request, so every page reads in the system stack. */ :root{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); - --radius:9px; + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); + --radius:8px; --font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color-scheme:light; } @media (prefers-color-scheme:dark){:root{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; }} :root[data-theme=light]{ - --paper:#eef0f3; --surface:#ffffff; --surface-2:#f7f8fa; - --ink:#16171c; --ink-2:#43454d; --ink-3:#71737c; - --line:#e2e4e9; --line-2:#d0d2d9; - --brand:#d23b2e; --brand-soft:#fdefed; --brand-line:#f3b3ac; - --ok:#10876e; --ok-soft:#e9f9f2; - --shadow:0 1px 2px rgba(16,17,22,.06); - --shadow-pop:0 1px 2px rgba(16,17,22,.06),0 8px 24px rgba(16,17,22,.10); - --window-shadow:0 2px 6px rgba(16,17,22,.10),0 18px 48px rgba(16,17,22,.16); + --paper:#efecf4; --surface:#ffffff; --surface-2:#f6f4f8; + --ink:#171320; --ink-2:#453f52; --ink-3:#665e72; + --line:#e6e2ec; --line-2:#cfc9d8; + --brand:#e02b20; --brand-soft:#fff1f0; --brand-line:#ffb3ad; + --ok:#0b8157; --ok-soft:#e9f9f2; + --shadow:0 1px 2px rgba(23,19,32,.08); + --shadow-pop:0 1px 2px rgba(23,19,32,.08),0 4px 16px rgba(23,19,32,.12); + --window-shadow:0 4px 16px rgba(23,19,32,.12),0 16px 48px rgba(23,19,32,.22); color-scheme:light; } :root[data-theme=dark]{ - --paper:#0e0f12; --surface:#17191e; --surface-2:#1d1f25; - --ink:#f1f2f4; --ink-2:#bcbfc7; --ink-3:#8b8e97; - --line:#282a31; --line-2:#363942; - --brand:#f0685c; --brand-soft:rgba(240,104,92,.14); --brand-line:rgba(240,104,92,.38); + --paper:#0e0c13; --surface:#171320; --surface-2:#1e1a28; + --ink:#f3f1f7; --ink-2:#bdb7c8; --ink-3:#8d86a0; + --line:#2a2534; --line-2:#372f45; + --brand:#ff6b60; --brand-soft:rgba(255,107,96,.14); --brand-line:rgba(255,107,96,.38); --ok:#2fb89b; --ok-soft:rgba(47,184,155,.14); --shadow:0 1px 2px rgba(0,0,0,.4); - --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 8px 24px rgba(0,0,0,.5); - --window-shadow:0 2px 6px rgba(0,0,0,.5),0 18px 48px rgba(0,0,0,.6); + --shadow-pop:0 1px 2px rgba(0,0,0,.4),0 4px 16px rgba(0,0,0,.5); + --window-shadow:0 4px 16px rgba(0,0,0,.5),0 16px 48px rgba(0,0,0,.6); color-scheme:dark; } /* /vstack:shell tokens */ @@ -256,8 +265,10 @@ .banner.good .tick{color:var(--ok);font-weight:700} /* the toast — VSShell.toast(); one element, appended on first use */ -.vs-toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; - background:var(--ink);color:var(--surface);padding:8px 14px;border-radius:8px; +/* It is a popover, so the browser's own [popover] rules apply first: inset, + margin and border are theirs to set and ours to put back. */ +.vs-toast{position:fixed;inset:auto;left:50%;bottom:18px;transform:translateX(-50%);z-index:80; + background:var(--ink);color:var(--surface);border:0;margin:0;padding:8px 14px;border-radius:8px; font-size:12.5px;box-shadow:var(--shadow-pop);opacity:0;pointer-events:none;transition:opacity .25s} .vs-toast.on{opacity:1} @@ -517,11 +528,16 @@ + + +
@@ -801,14 +817,34 @@

} /* ── a toast: the page saying "done" without stopping anyone ── */ + /* Long enough for the opacity transition in shell.css to finish before the + toast leaves the top layer, so it fades rather than vanishing. */ + const TOAST_FADE_MS = 300; let toastTimer = null; function toast (msg, ms = 2200) { let el = document.querySelector('.vs-toast'); - if (!el) { el = document.createElement('div'); el.className = 'vs-toast'; document.body.appendChild(el) } + if (!el) { + el = document.createElement('div'); + el.className = 'vs-toast'; + /* A modal dialog paints in the top layer, above every z-index there is, + and its backdrop blurs what lies under it. A toast raised while one is + open has to join the top layer or it is unreadable behind the very + dialog whose failure it is reporting. */ + el.popover = 'manual'; + document.body.appendChild(el); + } el.textContent = msg; + /* Promoted on each toast rather than left open, because the top layer + 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 {} el.classList.add('on'); clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove('on'), ms); + toastTimer = setTimeout(() => { + el.classList.remove('on'); + toastTimer = setTimeout(() => { try { el.hidePopover() } catch {} }, TOAST_FADE_MS); + }, ms); } /* ── two-step confirm on one button ── @@ -916,6 +952,9 @@

const btn = $('#settingsBtn'), menu = $('#settingsMenu'); if (!btn || !menu) return; const open = on => { menu.hidden = !on; btn.setAttribute('aria-expanded', String(on)) }; + // A control in the cog's slot can act on the page behind it, so the page + // needs a way to put the menu away first. + closeSettings = () => open(false); btn.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden) }); menu.addEventListener('click', e => e.stopPropagation()); document.addEventListener('click', () => open(false)); @@ -949,9 +988,12 @@

return api; } + let closeSettings = () => {}; + const api = { init, setTheme, setLang, setLink, setWatching, setServerVersion, hideLink, name, wip, connect, toast, armConfirm, esc, + closeSettings: () => closeSettings(), get theme () { return theme }, get lang () { return lang }, onLang (fn) { langListeners.push(fn) },