Product screenshots: always fresh
The screenshots on /brand (Screenshots) and /press (media, zips) must show the current release. This document
lists the current assets, describes a capture pipeline for each surface, and describes how CI keeps them fresh.
Status: terminal is automated (script/capture-assets.ts). The other surfaces are designs with commands and blockers.
1. Current assets (Nov 2 2026)
Latest release: v2.2.5 (Oct 30 2026). Source: gh api 'repos/anomalyco/opencode/releases?per_page=8'.
| Used on | File | Source | Shows | Size | Mode | Problem |
|---|---|---|---|---|---|---|
| /brand Desktop | public/product-videos/capture-straight/desktop-landscape.jpg |
Frame 0 of a video. Storybook stories + Playwright, then the CSS 3D compositor | Storybook build, model "Claude Sonnet 4" | 1920×1080 JPG, 1× | Dark | Not the app. UI fills about 40% of the frame. Wrong model name |
| /brand Mobile | .../capture-straight/mobile-landscape.jpg |
Same as above, phone layout | "Claude Sonnet 4" | 1920×1080 JPG | Dark | Small phone in a landscape frame. Wrong model name |
| /brand Terminal (preferred) | .../capture-straight/terminal-landscape.jpg |
VHS + OpenCode TUI 2.2.2, live free model | Model "Big Pickle" | 1920×1080 JPG | Dark | 3 releases old. Wrong model name |
| /press stills (15) | public/press-media/frames/<slug>-frame-N.jpg |
script/press-media.sh takes frames from real-straight/*.mp4 at fixed times |
desktop: packages/app origin/dev bf544cd419 + e2e mock server; mobile: opencode-ios in the simulator; terminal/sdk: TUI 2.2.2 |
1920×1080 JPG (from H.264) | Dark | Frames from a video with zoom and motion blur. Some are crops in the middle of a camera move |
| /press films, posters | public/product-videos/{real,capture}-straight/*.{mp4,jpg} |
Same recordings | 2.2.2 / origin/dev Oct 31 | 1920×1080, 1080×1350 | Dark | Posters are video frames, not stills |
| /press feature clips | public/product-videos/features/<slug>-N.{mp4,jpg} |
Same recordings | Same | 1600×1000 | Dark | Same |
| Zips | public/opencode-press-media.zip (71 MB), public/press-media/zips/* |
script/press-media.sh |
No version or date in the zip |
Common problems:
- No still is captured as a still. All of them come from H.264 video at 1×, so text is soft and there are no 2× files.
- Dark only. No light set.
- No manifest. Nothing records the product version or the capture date, so nothing can say when the assets are stale.
- The recordings are in a scratch folder (
$TMPDIR/opencode/product-videos/{real-cli,real-ui,real-ios}, wiped by a reboot on Nov 3; the video editor now readsPRODUCT_VIDEOS_DIR, defaultproduct-videos/next to the checkout), not in the repo. Only the person who made them can run them again. public/product-videos/andpublic/press-media/are not tracked in git yet.
2. Pipeline
Rules for every surface:
- Real product UI. Use the released product (or a build of the release tag). Do not use Storybook for official stills.
- Demo data only. Use an isolated
HOME, a fixture repo created by the script, and no real config, auth or sessions. - Stable text. A scripted model (
script/assets/stub-model.ts) replaces only the model. OpenCode still runs the tools: reads, the edit and its diff, the permission prompt andbun test. The model is named "Claude Opus 5.5" (provider "OpenCode"). - Fixed geometry. Logical window 1600×1000 at 2× (3200×2000 PNG). Light and dark from the product's own theme setting.
- Names.
<surface>/<surface>-<beat>-<mode>@2x.png, for exampleterminal/terminal-permission-light@2x.png. - Manifest.
manifest.jsonrecords the OpenCode version, the capture time, the generator commit, and the width, height, size and SHA-256 of each file.
Terminal (TUI): automated
cd packages/console/app
bun script/capture-assets.ts # about 1 minute; writes ../../../../fresh-assets (next to the checkout)
How it works:
- It starts the scripted model on a free local port (Bun.serve, OpenAI-compatible SSE).
- For each mode, it writes an isolated
HOMEwithopencode.json(providerdemo, model "Claude Opus 5.5"),cli.json(theme: { name: "opencode", mode }, or the--themeand--experimentoptions) and themy-appfixture repo (gitmain, fixed commit date). It also dismisses the sidebar "Getting started" card. That card shows when no real provider is connected. - It writes a VHS tape that runs
opencode --standalone, types the prompt and takes threeScreenshots:home(prompt typed),permission(diff and "Allow once"), anddone(tests pass and summary).Wait+Screenreacts to the real UI, so there are no fixed sleeps for model output. - It writes
manifest.jsonandopencode-terminal-screenshots.zip.
Requirements: opencode (or OPENCODE_BIN=/path/to/build), vhs 0.12+ (brew install vhs, which installs ttyd and uses headless Chrome), bun, git and zip. Font: --font / CAPTURE_FONT (default OpenTUI Mono, which must be installed; its OFL files are in anomalyco/opentui packages/web/public/fonts).
Output: fresh-assets/ next to the checkout (override with --out or FRESH_ASSETS_DIR; not the temp folder, which a reboot wipes): 6 PNGs, 3200×2000, light and dark, manifest and zip. The /brand terminal pair is the done beat, copied to public/brand/screenshots/opencode-terminal-{dark,light}.png with 1600×1000 WebP previews (cwebp -q 82 -m 6 -resize 1600 1000), and manifest.json next to them feeds the "captured from" line.
Track the TUI on dev
The terminal capture must track the TUI version on dev, not only the last release: TUI design work lands on dev
first, often behind a TUI experiment flag, and the site must show it. On Nov 5 2026, James Long's dialog redesign
(muted-blue dialogs and permission dock with a blue key line on the left, instead of orange; #55498–#55751, #59996,
#60016, #60310) was on origin/dev behind experimental["new-designs"], and the footage from v2.2.5 still showed the
orange dialogs. To capture from dev:
git worktree add --detach ../tui-dev origin/dev && cd ../tui-dev && bun install
cd packages/cli && OPENCODE_VERSION=<version in packages/cli/package.json> bun run script/build.ts --single --skip-install --skip-web-ui --outdir=<build dir>
cd <site checkout>/packages/console/app
OPENCODE_BIN=<build dir>/cli-darwin-arm64/bin/opencode bun script/capture-assets.ts --surface terminal --surface terminal-film \
--experiment new-designs --theme opencode2 --out <out>
--experiment <name>(repeatable) writesexperimental: { <name>: true }into the fixture'scli.json.--themesets the theme;opencode2is the new-designs theme, offered only with that experiment on.- Set
OPENCODE_VERSION, or the home screen shows0.0.0-detached-<date>. - Each time the TUI on
devchanges its look, capture again, copy the films toproduct-videos/captures/terminal-film/next to the checkout, and run the edits there (cinematic/edits/*terminal*.json,press-terminal.json). The new films keep the old beats within one frame, so the edits need no new timings. Then copy the outputs intopublic/product-videos, runscript/press-media.sh, and re-run the media variants. - Check that no old orange is left:
product-videos/cinematic/orange-check.py(next to the checkout) finds solid regions of the old accent (#FAB181). Chrome (chips, key lines, selection bars) shows as regions of thousands of pixels; syntax-highlighted code stays under 100. - When the experiment ships as the default, drop the two flags.
Text that still changes between runs: the run time and tok/s in the footer (Build · Claude Opus 5.5 · 5.6s · 155.2 tok/s), the "Thought · Nms" time and the bun test time ([6.00ms]). For that reason the SHA-256 changes on each run. To make the files byte-stable, the product team would need a frozen clock in the CLI (see section 5). Until then, compare captures with a perceptual diff, not with the hash.
SDK: code-snippet render (not automated yet)
The SDK has no UI. Two options:
- Terminal capture (preferred). Add a second VHS tape that opens the snippet in nvim and runs it in a split. This exists as a recording in
real-cli/term/gen.py(sdk2-*). Move it intocapture-assets.tsassurfaces.sdk, with the same stub model (it already replays the SDK steps) andScreenshotinstead of video. - Static render. Render the snippet from
src/component/site/products.tsxwith Shiki and the OpenCode light and dark syntax themes in headless Chromium at 2×. This is simple and fully deterministic, but it does not show the product running.
The site's sdkSnippet shows the v2 @opencode/sdk API (OpenCode.create()); render that.
Web: Playwright against packages/app (not automated yet)
packages/app already has the pieces: e2e/utils/mock-server.ts, mock-api.ts and session-fixture.ts answer every API call and push schema-validated session events. The real-ui recording ($TMPDIR/opencode/product-videos/real-ui/drive/) shows that this works with page.clock (fake clock) for deterministic frames.
Plan: a Playwright spec in script/assets/web.spec.ts that starts the app's Vite server from the release tag, opens a fixture session ("SAVE10 discount support"), plays the same scripted events, and takes:
await page.emulateMedia({ colorScheme: mode })
await page.setViewportSize({ width: 1600, height: 1000 }) // context: { deviceScaleFactor: 2 }
await page.clock.install({ time: new Date("2026-01-01T12:00:00Z") })
await page.screenshot({ path: `web/web-session-${mode}@2x.png`, animations: "disabled" })
Blockers: the mock server lives in packages/app/e2e and is not exported. The product team must keep it stable or publish it as a fixture. The app's theme preference must be settable before load (local storage key or query parameter).
Desktop: Electron with Playwright (not automated yet)
playwright._electron.launch({ executablePath, args: [...], env: isolatedEnv }) against a packaged build of the release, with a demo workspace (the same my-app fixture) and the stub model. Use electronApp.firstWindow(), set the window to 1600×1000 with BrowserWindow.setContentSize through electronApp.evaluate, and use page.screenshot() at device scale factor 2 (macOS Retina, or --force-device-scale-factor=2).
Blockers:
page.screenshot()cannot see nativeWebContentsViews (for example the in-app browser pane). Either skip those beats or usewebContents.capturePage()in the main process and compose the two images.- The desktop app needs a flag or an environment variable for an isolated user-data folder, a theme mode and no first-run onboarding. Check
packages/desktop/srcfor existing flags; if there are none, the product team must add them. - Packaged builds are signed and notarized. CI needs a macOS runner and the release artifact.
Until then, the web capture with the desktop window chrome (vertical tabs, titlebar) is a good fallback, as real-ui did.
Mobile: iOS Simulator (not automated yet)
The real-ios recording builds anomalyco/opencode-ios in the simulator, drives it with XCUITest and talks to a local demo server with the stub model. For stills:
xcrun simctl boot "iPhone 17 Pro"
xcrun simctl status_bar booted override --time 9:41 --batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3
xcrun simctl ui booted appearance light # then dark
xcodebuild test -scheme OpenCode -only-testing:OpenCodeUITests/ScreenshotTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro'
xcrun simctl io booted screenshot mobile/mobile-session-light@3x.png
Simulator screenshots are native resolution (3× on Pro devices, 1206×2622). Keep them at 3×; the manifest records the scale.
Blockers: the iOS repo needs local patches to build with Xcode 26.3 (real-ios/ios-build-fixes.patch). The team builds with Swift 6.4. A ScreenshotTests UI test target and a demo-server launch argument must be in the iOS repo. This work belongs to the iOS team.
3. Freshness
Capture
script/capture-assets.ts is the single entry point. Each surface is one function in surfaces. The plan is to replace script/press-media.sh with it: stills come from the capture, not from video frames. The films stay as they are and get their own manifest entries.
Proposed output in the site: public/product-assets/<surface>/*.png plus public/product-assets/manifest.json. JPG and WebP versions for the page and the thumbnails come from the PNGs.
Check
GITHUB_TOKEN=$(gh auth token) bun script/check-assets.ts --manifest public/product-assets/manifest.json --max-behind 3
- Fails if the manifest is missing, if its major.minor is older than the latest release, or if it is more than
--max-behindstable releases behind. - Warns if it is 1 to
--max-behindreleases behind. - Only
vX.Y.Ztags count. Other tags, such asprocess-daemon-v…, are ignored.
Example with the current assets (2.2.2): warning: Captured from v2.2.2 on 2026-10-31; latest is v2.2.5 (3 releases behind). With --max-behind 2, it fails.
CI (proposal, not added)
.github/workflows/capture-assets.yml:
name: capture-assets
on:
release:
types: [published]
workflow_dispatch:
schedule:
- cron: "0 6 * * 1" # weekly check
jobs:
capture:
if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with: { ref: v2 }
- uses: oven-sh/setup-bun@v2
- run: brew install vhs
# The released CLI (the package behind ~/.bun/bin/opencode). On workflow_dispatch, use the latest.
- env: { TAG: "${{ github.event.release.tag_name }}" }
run: |
VERSION="${TAG#v}"
bun add -g --trust "@opencode/cli@${VERSION:-latest}"
- run: bun install
- name: Capture
working-directory: packages/console/app
env: { CAPTURE_FONT: "JetBrains Mono" }
run: bun script/capture-assets.ts --out public/product-assets
- uses: peter-evans/create-pull-request@v7
with:
branch: fresh-assets
title: "chore(console): capture product screenshots from ${{ github.event.release.tag_name || 'latest' }}"
body: "Automatic capture. Review the images before merging."
add-paths: packages/console/app/public/product-assets
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- working-directory: packages/console/app
env: { GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" }
run: bun script/check-assets.ts --manifest public/product-assets/manifest.json
Add the check job to the site's build workflow too, as a warning only (continue-on-error: true), so a release does not block site deploys.
Show the version on /brand and /press (proposal, not applied)
src/lib/brand-content.ts and src/lib/press-content.ts already read JSON manifests. Add:
import assetManifest from "../../public/product-assets/manifest.json"
export const capturedFrom = {
version: assetManifest.opencodeVersion,
date: assetManifest.capturedAt.slice(0, 10),
}
// Screenshots body: `Captured from v${capturedFrom.version} on ${formatDate(capturedFrom.date)}.`
Show it under the Screenshots title on /brand, next to "Download all" on /press, and in the Markdown versions (brandMarkdown, press Markdown). Write manifest.json into every zip (the capture script already does this).
4. Automatable now vs needs the product team
| Surface | Now | Needs |
|---|---|---|
| Terminal | Yes. capture-assets.ts, light and dark, 2×, about 1 minute |
A frozen clock for byte-stable files (optional) |
| SDK | Yes, after a small port of the existing nvim tape | A decision on the SDK snippet (v1 vs v2 API) |
| Web | Mostly. The e2e mock server and the fake clock already work | A stable, exported fixture server. A theme setting before load |
| Desktop | Partly. Playwright can drive Electron | Flags for isolated user data, theme and no onboarding. A capture path for native views. A macOS CI runner with the signed build |
| Mobile | Manual. Simulator scripts exist in a scratch folder | A UI-test screenshot target and a demo-server launch argument in opencode-ios. Toolchain match (Swift 6.4) |
5. Requests for the product team
- CLI: a capture mode. An environment variable, for example
OPENCODE_CAPTURE=1, that freezesDate.now()and run timings, hides the version in the corner (or makes it explicit), and skips first-run cards. Then the files are byte-stable and the manifest hash shows real UI changes. - Desktop: launch flags for user-data folder, theme mode and onboarding.
- App: export the e2e mock server as a fixture package.
- iOS: a
ScreenshotTeststarget that uses a launch argument for the demo server URL.