feat(docs): capture screenshots from the pages that declare them - #6921
Open
geovannewashington wants to merge 2 commits into
Open
feat(docs): capture screenshots from the pages that declare them#6921geovannewashington wants to merge 2 commits into
geovannewashington wants to merge 2 commits into
Conversation
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.
geovannewashington
force-pushed
the
feat/docs-shot-manifest
branch
from
August 18, 2026 20:07
f76d27f to
dfafd6e
Compare
Member
|
/review |
Code Review CompleteThe 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inshellhub-demoreads 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.astrorenders<img src="/img/shots/<id>.png">and callsrecordShot(). Declarations accumulate inregistry.ts, which stores them onglobalThisunder aSymbol.forkey 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, editionce), collapses declarations by id, and sorts. Two pages declaring the same shot identically is fine and records both underusedBy. 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 onastro:build:start(a watched rebuild reuses the process), write the manifest onastro:build:done, then check every id has a PNG on disk. Missing images warn locally and throw underCI, since a missing image is normal while drafting and unacceptable when publishing. Inastro dev, which never firesbuild: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
SSHLegacyAllowedinpkg/models/namespace.gois 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.gifstays 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 theshellhub-democheckout beside this one (overridable withSHELLHUB_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.tsandbuild.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/docsExercising the pipeline. You need a
shellhub-democheckout beside this one and its companion PR checked out. Fromshellhub-demo:Then from
ui/apps/docsin this repo:Step 3 should report
10 capturedand write PNGs intopublic/img/shots/. Narrow it while iterating withnpm run shots -w @shellhub/docs -- --only device-details,web-endpoints.Then
git statusonpublic/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.shexists 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 whynpm run shotsis one command from the docs app.