Skip to content

feat(docs): capture screenshots from the pages that declare them - #6921

Open
geovannewashington wants to merge 2 commits into
masterfrom
feat/docs-shot-manifest
Open

feat(docs): capture screenshots from the pages that declare them#6921
geovannewashington wants to merge 2 commits into
masterfrom
feat/docs-shot-manifest

Conversation

@geovannewashington

@geovannewashington geovannewashington commented Aug 18, 2026

Copy link
Copy Markdown
Member

What

Documentation screenshots are now declared by the pages that use them and taken automatically, instead of being produced by hand.

A page writes a <Shot> tag. The tag renders the image and registers what it takes: the route, the element, the viewport, the edition. The docs build collects every declaration into .astro/shots.json. A companion change in shellhub-demo reads that file and photographs a real seeded instance.

That JSON file is the only contact between the two sides. The docs contain no code that talks to a running ShellHub, and the demo has no idea the documentation exists.

Closes shellhub-io/team#194.
Companion PR: shellhub-io/shellhub-demo#4 (required for the capture step; the docs side builds and ships without it).

Why

Every screenshot was taken by hand. To refresh one you had to stage an instance, arrange the data until the page looked right, take the picture, crop it, and drop it in the right folder. Nothing tied an image to the page using it, so a picture could go stale, or outlive its page entirely, and nothing would say so.

Making the page the single place a screenshot is declared removes that whole class of drift. Remove the tag and the capture stops. Two pages wanting the same picture share one id and one file. And because the coordinates live beside the prose, the person editing the page is the person who notices when the picture no longer matches.

The second goal was reproducibility. The same page has to produce the same bytes twice, or every refresh churns the repository and a reviewer cannot tell which images actually changed. The browser is pinned, the clock is frozen, and API timestamps are rewritten against that frozen instant so "3 minutes ago" stays "3 minutes ago" between runs an hour apart.

Changes

<Shot> and the registry. Shot.astro renders <img src="/img/shots/<id>.png"> and calls recordShot(). Declarations accumulate in registry.ts, which stores them on globalThis under a Symbol.for key rather than in a module-level array: the component loads through Vite's SSR module graph and the integration through Node's, each graph gets its own copy of a module, and a plain array would be written by one and read by the other. That failure mode is an empty manifest and a green build, so it is worth the indirection.

The manifest. buildManifest() fills in defaults (1440x900, edition ce), collapses declarations by id, and sorts. Two pages declaring the same shot identically is fine and records both under usedBy. Two pages declaring the same id with different coordinates fails the build, because they would otherwise fight over one filename. The sort exists because Astro renders pages concurrently, so without it the file's line order shuffles between runs that changed nothing.

The integration. shots() is a small Astro integration: clear the registry on astro:build:start (a watched rebuild reuses the process), write the manifest on astro:build:done, then check every id has a PNG on disk. Missing images warn locally and throw under CI, since a missing image is normal while drafting and unacceptable when publishing. In astro dev, which never fires build:done, the warning comes from the render instead.

Image reorganisation. public/img/shots/ is generated and owned by the pipeline. public/img/manual/ holds the images that cannot be produced by it, for three distinct reasons.

The gifs are animations. The pipeline writes PNGs, so anything showing a sequence of interactions stays hand-recorded.

Firewall rules and public keys document the legacy SSH access model, and the demo cannot create a namespace that uses it. Namespaces are born in identity mode, and SSHLegacyAllowed in pkg/models/namespace.go is what governs switching back: only grandfathered namespaces may, and one the demo creates never can. Automating those two shots would mean writing to Postgres behind the server's back to forge a grandfathered namespace, seeding firewall rules and keys into it, and photographing a model these pages already mark deprecated. The existing images stay because the pages still ship, and the demo seeds Access Policies and SSH Identities instead, which are the identity-mode replacements and are captured.

The MFA screenshots are a deliberate follow-up rather than a permanent exclusion. Four of the five could be automated, but not as ordinary shots: the setup wizard is linear and one-way, so its screens only exist after the previous step is completed and cannot be replayed once MFA is enabled. Capturing them needs a scripted flow against a throwaway account rather than the shared signed-in session, a TOTP code computed at capture time, and a separate browser context for the logged-out login prompt. That is worth doing and is not worth blocking this on. The mfa-enable.gif stays manual regardless, being an animation.

scripts/capture-shots.sh. A thin wrapper so you don't have to switch directories and type two long paths. It knows where the manifest is written and where the images are read from, finds the shellhub-demo checkout beside this one (overridable with SHELLHUB_DEMO_DIR), and hands both paths over. The demo deliberately knows neither, which is what keeps a demo-environment builder free of any knowledge of the documentation that consumes it. That constraint applies to the demo, not to the caller, and this side knows both paths perfectly well.

Testing

Unit tests. 13 tests across manifest.test.ts and build.test.ts, covering the merge and dedup rules, the defaults, the duplicate-id rejection, deterministic ordering, and an end-to-end build of a fixture site that asserts the manifest lands and the <img> renders.

npm run test -w @shellhub/docs

Exercising the pipeline. You need a shellhub-demo checkout beside this one and its companion PR checked out. From shellhub-demo:

# 1. A real, seeded instance. This is what the pictures are of.
./stage up --edition enterprise --license <path/to/license.dat> --seed full

Then from ui/apps/docs in this repo:

# 2. Build the docs. This is what writes the manifest.
npm run build -w @shellhub/docs   # prints: wrote 10 shots to .astro/shots.json

# 3. Take the pictures.
npm run shots -w @shellhub/docs

Step 3 should report 10 captured and write PNGs into public/img/shots/. Narrow it while iterating with npm run shots -w @shellhub/docs -- --only device-details,web-endpoints.

Then git status on public/img/shots/ is the real check: on an unchanged UI, a second run should leave the directory clean. Any file that shows as modified is a pixel that moved.

What to verify by eye. A green [ ok ] only means a PNG was written. A "Device not found" panel, an empty table, or the wrong namespace all photograph perfectly and keep the run green. Open the images before trusting them.

Two notes worth knowing

You can build the manifest from a development HEAD and hand it to a demo running a production release. The manifest is generated from the docs and carries no version, only coordinates. So there is no need to wait for a release just to take screenshots, as long as your change doesn't move the UI elements the manifest points at. If it does, the run tells you: a moved route fails the redirect assertion and a renamed element fails its wait, both loudly. What it cannot tell you is a change that only alters appearance, since that photographs green against an older build and quietly shows the old look. Screenshots of a visual change therefore have to wait for the release containing it.

scripts/capture-shots.sh exists purely for ergonomics. Without it, every capture means switching to the demo checkout and typing the full path to both the manifest and the output directory. The wrapper fills both in, which is why npm run shots is one command from the docs app.

@geovannewashington geovannewashington self-assigned this Aug 18, 2026
@geovannewashington
geovannewashington requested a review from a team as a code owner August 18, 2026 19:28
@geovannewashington geovannewashington added javascript Pull requests that update Javascript code area/docs labels Aug 18, 2026
Screenshots were taken by hand, so nothing connected an image to the page
using it. A picture went stale silently, and refreshing one meant staging an
instance, arranging the data, and cropping by eye.

A page now declares what it needs. The <Shot> tag renders the image and
registers its coordinates - route, element, viewport, edition - and an Astro
integration collects them at build time into .astro/shots.json. That file is
the only contact between the two sides: shellhub-demo reads it and photographs
a seeded instance, and the docs hold no code that talks to a running ShellHub.

Because the tag is both the declaration and the display, the list cannot
drift. Removing a tag stops the capture, two pages sharing an id share one
image, and the same id declared with different coordinates fails the build. An
id with no image on disk warns locally and fails in CI.

Images split accordingly. public/img/shots is generated and owned by the
pipeline. public/img/manual holds what a browser cannot take unattended: the
gifs, and the firewall rules and public keys pages, whose legacy namespaces
can no longer be created and so cannot be reproduced.
The screenshot pipeline could add an image but never take one away. A page
that dropped its <Shot>, or a manual PNG whose section was rewritten, left
the file sitting in public/ for good: nothing pointed at it, nothing said
so, and the only way to notice was to remember.

The build now reads its own output and collects every /img/ URL the site
asks for, whatever asked for it - a <Shot> tag, a markdown image, a raw
<img>. Anything in img/shots or img/manual that no page requests is a
leftover, and locally the build deletes it and says which. Under CI it
fails instead: a build that quietly rewrites the checkout hides the fact
that the removal was never committed.

Scanning the built HTML rather than the sources is what makes this cover
img/manual at all, since those images are referenced by hand and no
registry knows about them. Directories the docs do not own are left alone,
and a build that produced no pages proposes nothing, so a broken build
cannot be read as a site that stopped using every image it has.
@otavio

otavio commented Aug 18, 2026

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Code Review Complete

The automated review ran but did not post an updated summary — this usually means no new issues were found since the previous review. If you've pushed changes and want a fresh pass, comment /review.

View job

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants