Skip to content

feat: storybook with deterministic visual regression baselines - #444

Merged
Neziahtech merged 8 commits into
Neziahtech:mainfrom
onajidavid87-web:docs/storybook-visual
Oct 29, 2026
Merged

Neziahtech merged 8 commits into
Neziahtech:mainfrom
onajidavid87-web:docs/storybook-visual

Conversation

@onajidavid87-web

Copy link
Copy Markdown
Contributor

Summary

Closes #93

Adds a Storybook documentation site for the frontend component set, plus a visual-regression gate in CI. A UI change now shows up in the PR as an image diff instead of something a reviewer has to spot by eye. Storybook builds against the app's own Next 16 / React 19. The friction that combination caused, and how each problem was resolved, is documented in docs/STORYBOOK.md.

What's included

Storybook

  • @storybook/nextjs-vite@10.6.5, using the app's own next@16, react@19 and react-dom@19 (no second copy of either). It reuses the vite@8 the vitest suites already pin, so the lockfile gains no second bundler.
  • 59 stories across 15 components, colocated as components/**/*.stories.tsx.
  • Every composite component has a story for each of its states (loading / connected / error, plus others where relevant):
Component States covered
Dashboard Disconnected, Connecting, ConnectedEmpty, ConnectedWithStreams, DiscoveredOnChain, LookupLoading, LookupError
StreamDetailClient Loading, ConnectedRecipient, ConnectedSender, RefundRouted, OverWithdrawal, ReadError, ReadOnly, Cancelled
StreamForm / BatchStreamForm Default, PolicyLoading, TokenContract, PolicyViolation, InvalidDeposit / InvalidRow, Submitting
WalletButton / WalletModal Disconnected, Connecting, Connected, FreighterMissing / Installed, ConnectError
NetworkBadge, ClaimSchedulerSettings, DiscoveryConsentModal, StreamList, Toast, Charts, panels populated, empty, loading and error variants
  • Play functions double as interaction tests: each one asserts the state its screenshot is meant to show.
  • A toolbar locale switch (en / de) wraps every story in the real LocaleProvider.

Deterministic rendering

  • Injected fake clock (.storybook/fake-clock.ts): Date is frozen at 2026-01-15T12:00:00Z for every story. The live-accrual ticker still fires each second, but every tick sees the same instant, so the "Available now" counter renders identical digits on every run. Real timers are left untouched, so React and Storybook scheduling are unaffected.
  • Hermetic stories:
    • Server actions, the wallet context and the discovery service are mocked through sb.mock.
    • Every NEXT_PUBLIC_* value is pinned, including an RPC URL pointing at a closed port.
    • The suite fails any story that makes a request outside the Storybook server.
  • One renderer: baselines are rendered and compared inside mcr.microsoft.com/playwright:v1.64.1-noble, at maxDiffPixels: 0. A CI step fails if @playwright/test and the image tag drift apart.

CI: new storybook-visual job

  1. npm ci → check that the Playwright version matches the container → npm run build-storybook.
  2. npx playwright test: one full-page screenshot per story, compared against frontend/visual/__screenshots__/linux/.
  3. Always uploads the visual-diffs artifact (expected / actual / diff PNGs, plus the HTML report).
  4. On failure, re-renders candidate baselines in the same image and uploads them as visual-baselines-linux, so baselines can be updated without Docker. CI never writes or commits a baseline itself.
  5. All new steps are registered in .github/ci-gates.json, so removing one later trips the workflow-integrity gate.

Demo: the gate catches a real style change

frontend/visual/detector.visual.ts runs on every PR as a self-test of the gate:

  • Regression caught: applies a one-line padding change to .status-badge and requires the comparison against the committed baseline to fail. The resulting diff images are in the visual-diffs artifact on every run.
  • Clock frozen: two captures of the live stream page 2.5 s apart must be byte-identical. As a positive control, advancing the fake clock one minute must change the counter.

First-run baselines

The baselines were rendered in the pinned container and committed in a separate commit with a review note. All 59 PNGs were inspected before committing.

Next 16 / React 19 friction and resolutions

Problem Resolution
app/actions.ts ("use server") imports server-only, which throws in a browser bundle Replaced completely by app/__mocks__/actions.ts. __mocks__ is a private App Router folder, so it is never routed.
lib/discovery/service.ts imports node:crypto lib/discovery/__mocks__/service.ts provides only the two exports the modal uses.
sb.mock(..., { spy: true }) rewrites type re-exports before types are stripped, which breaks lib/stellar.ts Not mocked. Token metadata is served from the session cache fetchAssetMeta already checks first.
vite-plugin-storybook-nextjs per-key NEXT_PUBLIC_* values override Storybook's env option, so a local .env.local leaked into renders A post-enforced Vite plugin pins the values last. Verified that values set in the shell leave no trace in the bundle.
TypeScript ** globs skip .storybook/, so the config was never type-checked tsconfig.json includes .storybook/** explicitly.
The "story done" signal is phase finished in Storybook 10, not completed The suite waits for finished and fails on errored / aborted.

No application source code was changed. The diff is limited to tooling config, stories, mocks, the visual suite, CI and docs.

How to verify

cd frontend
npm ci
npm run storybook            # browse http://localhost:6006
npm run build-storybook
npm run test:visual          # local run; compares against your platform's (git-ignored) baselines
bash scripts/visual-baselines.sh --check   # exact CI comparison, in the pinned container (Docker)

Storybook (@storybook/nextjs-vite 10.6.5) builds against the app's own
Next 16 / React 19, with stories for every component in components/ and
per-state stories (loading / connected / error) for each composite.

Determinism: every story runs on an injected frozen clock
(.storybook/fake-clock.ts), with server actions, the wallet context and the
discovery service mocked and every NEXT_PUBLIC_* pinned, so the live-accrual
counter and every other value render identically on each run.

CI: a new storybook-visual job builds Storybook and runs a Playwright
full-page screenshot diff per story inside the pinned Playwright container,
at zero pixel tolerance. It uploads the diffs as an artifact, and on failure
re-renders candidate baselines for review. Self-tests check that a one-line
CSS regression fails the comparison and that the clock holds the counter
still, with a positive control. The new steps are registered in
.github/ci-gates.json.

The Next 16 friction and how it was resolved is documented in
docs/STORYBOOK.md.
@netlify

netlify Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for streampay-org ready!

Name Link
🔨 Latest commit 2eb17a2
🔍 Latest deploy log https://app.netlify.com/projects/streampay-org/deploys/6ae3ac515b3465c2772cd07b
😎 Deploy Preview https://deploy-preview-444--streampay-org.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

A committed pixel baseline needs one deterministic state. Playwright's
animations:"disabled" pauses/cancels at capture time instead of removing the
animation, so the captured frame still depends on where in its cycle an
infinite animation (pulse, wallet-spin) or a restarted transition happened to
be, and two captures of the same settled render are not byte-identical.
Declaring animation/transition/caret away pins every element to its static
specified style - the same mechanism the app's own prefers-reduced-motion block
uses for .live-dot.
The 59 PNGs in frontend/visual/__screenshots__/linux are the candidate set
from the Storybook job of run 40926388369 (attempt 2, head 1cb573b), rendered
inside mcr.microsoft.com/playwright:v1.64.1-noble — the only renderer these
baselines are comparable against — and taken from the visual-baselines-linux
artifact (id 12919894826).

Before committing, every file was screened: PNG signature, IHDR first and IEND
last with no trailing bytes, no unrecognised chunks, no tEXt/zTXt/iTXt payload
chunks, and no duplicate content except one pair noted below. The set is
exactly the 59 story ids the suite asks for — no extra, no missing.

streams-streamform--default.png and streams-streamform--submitting.png are
byte-identical: the Submitting story's play function does reach the disabled
"Streaming…" button (its waitFor passes), but the render captured a moment
later is the untouched form, and the DOM snapshot CI writes at failure shows
the empty recipient and the enabled button. That is a story-level gap, not a
missing baseline, and it is called out in the PR rather than hidden by
retouching an image.
The self-test still fails on any byte of difference between the two captures —
that part is unchanged. What it could not say was *what* moved: run
40926388369 reports "render drifted while the clock was frozen, Received: -1"
and nothing else, which leaves every candidate (a late async update, a
remount, an animation the pin missed, a height change that rewrites the whole
fullPage image) equally plausible.

So the test now records the window it is asserting over: a MutationObserver
over the document for the whole 2.5 s (childList, characterData and attributes,
with old values), the scrollHeight and the live animation set each capture
saw, and — when the buffers differ — both PNGs plus that log as report
attachments, which CI uploads in visual-diffs. The summary goes into the
failure message itself, so the CI log alone distinguishes "the DOM changed"
from "the DOM was still and the pixels moved anyway".
Run 40941155592 proves the committed baselines: every one of the 59 stories
matched byte for byte in a fresh render of a different commit, and the two
self-tests' setup is right — only the live-accrual capture still drifts. It
also shows the evidence capture failing on its own: fs.promises.writeFile into
testInfo.outputDir hit ENOENT because Playwright creates that directory when it
writes its own artifacts, not before the test body runs, so the write threw and
replaced the drift failure with an unrelated error. tsc caught a second one:
`const log = (host.x = [])` infers never[].

Attachments now go out as `body` instead of touching the filesystem, the whole
capture is inside a try/catch whose only effect is a note on the failure
message, and the DOM-mutation log is inlined there too, so the CI log carries
the diagnosis even if the report never gets read.
The instrumented run answered its own question. CI reported, at head
635b7a7: 60 passed / 1 failed, and the failure message carried the
whole window —

  render drifted while the clock was frozen: 86948 vs 86957 bytes,
  scrollHeight 1063 -> 1064, animations none -> none, 4 DOM mutations
  [+3ms input#_r_1_ @Style = "caret-color: transparent !important;" (was "")
   ;; +56ms ... = "" ;; +2565ms ... ;; +2623ms ...]

No component re-rendered. The only four changes in the window are the
caret-hiding style Playwright writes on the *focused element* before it
takes a screenshot and restores right after — once per capture. Two
consequences, both fixed here:

 - the capture read scrollHeight and the animation list in `Promise.all`
   alongside the screenshot, so the measurement could land on either side
   of that toggle while a `fullPage` shot was also resizing the viewport.
   1063 vs 1064 is one pixel of document height, and one pixel of height
   rewrites the whole PNG — the byte difference is the harness, not the
   app. The page is now snapshotted in a single `evaluate` *before* the
   image, never in parallel with it.
 - a focused input means every capture styles the page differently during
   the shot than after it. Focus is dropped and the scroll reset once,
   before the first measurement.

The fix normalises the *harness* only, and only up front: nothing is
re-normalised between the captures, so if the app itself moves focus or
scrolls inside the window that is still drift. Those two values are now
reported in the failure message along with height and animations.

The comparison is unchanged — byte-for-byte PNG equality, maxDiffPixels
nowhere, retries 0 — and the positive control (tick the fake clock, the
counter must move) is unchanged.
The previous commit's instrumentation was supposed to explain the drift. It
did, and the answer is not what it looked like.

Decoding the two PNGs the test attached (run 40946791424, job 123705891314,
attachment files 051e6f1c… at 86,948 B and 3f2bde30… at 86,957 B):

  051e6f1c…  IHDR 1024 x 1063
  3f2bde30…  IHDR 1024 x 1064
  over the shared 1024 x 1063 area: 0 differing channel bytes
  the extra row: 10,14,22 … — the page background, one solid row

So the render did not move. Not one pixel of the 1,088,512 they share differs;
the second image is simply one row taller and the extra row is empty. The
caret-colour records the mutation log caught are Playwright styling the page for
its own screenshot and undoing it afterwards — real, visible, and irrelevant,
because PIN_RENDERED_STATE already forces `caret-color: transparent` on
everything and the pixels never changed. Chasing them would have been the wrong
fix, and blurring the element in the last commit changed nothing about the
numbers: still 86948 vs 86957, still 1063 -> 1064.

What does change the page is `fullPage`. A document's scroll height is never
smaller than its viewport, and a full-page capture resizes the viewport to the
document to frame it, then restores it. That round trip leaves the document one
CSS pixel taller here — deterministically, in both runs — so `scrollHeight` is
1064 where the content is still 1063, and the next full-page image carries one
more row of background. Two full-page captures of the same page are therefore
not byte-comparable no matter what the component does, which is a property of
the tool, not of the code under test.

Fix: pin the viewport to the page height + 60 px once, up front, and take
viewport screenshots. Nothing resizes during a capture any more, and the test
loses no coverage: the viewport contains the entire page, the comparison is
still byte-for-byte with no tolerance, retries stay 0, and a render that really
moved would fail exactly as before. The evidence block now also reports the
scroll offset and the focused element so the next surprise explains itself too.

Also verified while reading this run: with main merged into the branch
(globals.css +306 lines, StreamList and page.tsx changed) all 59 story baselines
still matched — 60 passed / 1 failed, and the `--update-snapshots=changed` pass
re-rendered 59 passed with nothing changed. The committed baselines are current.
@Neziahtech

Copy link
Copy Markdown
Owner

Landed: the gate is green at 2eb17a25 — 61/61, and I want to show the work behind that number

I pushed five commits onto this branch as a maintainer, so you are not on the hook for the parts that were repo plumbing rather than your code. Everything below was measured in CI, not on a laptop: the baselines in this repo only mean something if the same Playwright image rendered them.

The baselines are committed (cc84283f). 59 PNGs, taken from the visual-baselines-linux artifact of run 40926388369 — i.e. rendered by mcr.microsoft.com/playwright:v1.64.1-noble, this job's own image — with the run id, attempt and commit that produced them written into the commit message. Before committing I ran them through a byte-level screen (magic bytes, IHDR first and IEND last, no trailing bytes, no unknown chunks, no tEXt/zTXt/iTXt chunks that could carry payload, dimensions and SHA-256 of every file), and the set is exactly the 59 filenames the suite asks for. Screenshots are the one artifact in this repo that a reviewer cannot eyeball for tampering, so the screen is now part of the routine, and docs/STORYBOOK.md says so.

Determinism, measured twice rather than assumed.

  • Run 40941155592 re-rendered the committed set with updateSnapshots: none at a different commit than the one that produced the baselines: all 59 matched byte-for-byte. That is the cross-run property the whole gate depends on.
  • I then merged current main into the branch (da895b90), which is not cosmetic: globals.css grew 306 lines and StreamList.tsx / app/page.tsx changed. Every baseline still matched — and the job's --update-snapshots=changed pass re-rendered the stories and reported 59 passed with nothing rewritten. So the set is current against main, not just against this branch.
  • For the record on the pinning commit (1cb573ba): comparing the pre-pin and post-pin baseline artifacts, 47 of 59 were identical and 12 changed. PIN_RENDERED_STATE is not decorative — it moves a fifth of the suite.

The self-test that kept failing was the harness, not your component. The frozen-clock test asserted two captures 2.5 s apart were byte-identical and got 86,948 vs 86,957 with no explanation, which is undiagnosable, so I made it explain itself: a MutationObserver over the window, plus the height, scroll offset, focus and animation state each capture saw, plus both PNGs attached on failure. That run answered it, and the answer was not what it looked like. The log showed only four DOM changes — caret-color: transparent !important written and removed on an input, which is Playwright styling the page for its own screenshot — and I decoded the two attached PNGs: the images were identical in all 1,088,512 pixels they shared (1024 × 1063), and the only difference was that the second carried one extra row, painted rgb(10, 14, 22) — the page background. Nothing in the render moved; the canvas grew. A document's scroll height is never smaller than its viewport, and a fullPage capture resizes the viewport to the document and back, so two full-page captures are not byte-comparable no matter what the component does.

Fix (2eb17a25): pin the viewport to the page height once and shoot the viewport instead of fullPage. I want to be explicit that this is not a relaxation — the viewport is the whole page plus a margin, the comparison is still byte-for-byte with zero tolerance and retries: 0, and self-test 1 still proves the gate catches a one-line CSS regression. It only stops the tool from reshaping the thing it measures.

One thing I would like you to fix, and it does not block this merge: streams-streamform--default.png and streams-streamform--submitting.png are byte-identical. The Submitting story does drive a play function (recipient typed, button disabled, labelled "Streaming…") but that state is gone by the time the capture is taken, which the run's error-context.md DOM snapshot confirms — the recorded baseline is the empty form again. Two identical PNGs are a story that does not test what it claims to. Please make the Submitting story hold its state until the screenshot (the usual cause is an await userEvent chain that finishes and lets the component settle back, or a play that returns before the state it sets is stable), then re-render just that baseline with bash frontend/scripts/visual-baselines.sh or by pushing and taking the visual-baselines-linux artifact. I would rather land the gate now and keep that as a follow-up than let it sit unreviewed another fortnight.

Where it stands: all four workflows are green at 2eb17a25 — Workflow integrity, Infra, Fuzz Gate, and CI with every job including Contract — and the Storybook job reports 61 passed: 59 stories plus both self-tests. Before merging I ran the diff through the repo's malware pattern screen (exec/eval, egress, encoded blobs, env reads, pipe-to-shell, fs writes, obfuscation, lifecycle hooks) against a known-positive fixture first so a clean result means something: no hits, and the committed baselines re-hash byte-identical to the artifact set that was screened. I am merging now; the visual gate then runs on every push to main, which is the point of it. Thanks for building the part that mattered — the Storybook harness, the story corpus and the two gate self-tests were the design work here; the code I added was instrumentation to find out why one of them was red.

@Neziahtech
Neziahtech merged commit 60b57e8 into Neziahtech:main Oct 29, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: component documentation site with Storybook and visual regression

2 participants