April 16, 2026 | By Lou Kovacs, Freelance Consultant
I used to know a photographer named Rena who worked out of a closet on Vine Street. Not a studio. A closet. She'd converted the water heater space into a darkroom — blacked out the louver vent with gaffer tape, hung a red safelight from a coat hook, ran a garden hose through the wall for the rinse. The whole operation cost maybe eighty dollars and smelled like acetic acid and ambition.
Rena shot fashion. Editorial stuff, mostly. Black and white because she believed color was a crutch for people who didn't understand light. She'd shoot a roll of thirty-six, develop the negatives, make a contact sheet, and then sit there — one eye closed, loupe pressed to the other — scanning thirty-six tiny rectangles of silver gelatin for the one frame that had whatever she was chasing.
Not sharpness. Not composition. Not any of the things you can teach. Something else. Something she could only see when she saw it.
"The darkroom is where the work happens," she told me once. "The camera just collects evidence."
I thought about Rena the other day, when I found myself in essentially the same situation — staring at a grid of images, looking for something I'd know when I saw it — except my darkroom was a GitLab CI pipeline, my camera was a headless Chromium browser, and my negatives were PNG artifacts downloaded through a REST API at two in the morning.
The Problem
Here's the situation. You're building a web application. An ebook storefront, in this particular case — a publishing house catalog with book detail pages and checkout flows and all the trimmings. And you want it to look good. Not "functional" good. Not "the Bootstrap defaults but in a darker color" good. Actually good. The kind of good where someone loads the page and feels something before they read anything.
You've chosen a visual identity. Dark Academia — a design language built on mahogany and wine and gilt, the aesthetic of a library you'd find behind a hidden door in an Oxford college. Garamond for the body text. Fell English for the headings. A background made of layered noise and ruled-paper lines, like parchment that's been through a few centuries of marginalia.
The CSS is going to be seven hundred lines minimum. Custom properties for every color. Pseudo-elements for decorative flourishes. Box shadows nested four deep. Media queries that restructure the entire layout at 780 pixels. It's the kind of work where you change one variable and fourteen things move.
And here's the thing: you can't see any of it.
I don't mean you're blind. I mean you're writing CSS in a terminal. You're editing files on a machine that doesn't run the application. The app requires PostgreSQL, Entity Framework migrations, seed data, a Stripe test configuration, and a Playwright-capable browser environment with a specific set of system libraries. You could set all of that up locally, sure. Some people do. Some people also iron their socks.
Or you could build a darkroom in the pipeline.
The Camera
The camera is Playwright. Microsoft's browser automation framework, the spiritual successor to Puppeteer, now with .NET bindings and a Chromium that runs headless on Linux with the same rendering fidelity as the browser on your desk.
The film is xUnit. Each test case navigates to a route, waits for the page to settle, and fires page.ScreenshotAsync(). Full-page capture. Every route gets two shots — desktop viewport (1280×720) and mobile (390×844). Twenty-two images per test run, covering every public and private page in the application.
The development is the CI pipeline. GitLab runs the tests inside a purpose-built container image — dotnet-sdk-playwright — that bakes Chromium and all its system dependencies into the Docker layer so there's no network fetch at test time. The screenshots land in a directory, and the pipeline publishes them as downloadable artifacts.
The contact sheet is the artifact API. One glab command pulls the ZIP. Unpack it, and you're looking at your entire application through the eyes of a 1280-pixel viewport running on a Linux box in a Kubernetes pod on a machine under a desk in a room with too many monitors.
Here's what the loop looks like:
- Edit CSS
- Commit, push
- Pipeline runs (~8 minutes)
- Download artifacts
- Look at the pictures
- Go back to step 1
Eight-minute development cycles. No local server. No hot reload. No browser DevTools. Just the edit, the wait, and the contact sheet.
It sounds insane. It is insane, a little. But it works, and I'll tell you why: because the constraints force honesty.
The Loupe
When you're tweaking CSS in a browser with DevTools open, you're lying to yourself. You're seeing the change in isolation, in the moment, with the inspector panel eating a third of your viewport. You toggle a property, nod, toggle another. It looks fine. It always looks fine in DevTools, the same way food always looks fine under restaurant lighting.
The screenshot doesn't lie. The screenshot shows you the entire page, rendered exactly as a visitor would see it, at the exact viewport dimensions you specified, with no inspector panel, no hover states you're unconsciously triggering, no scroll position you've unconsciously optimized for. It shows you what's there.
The first time I downloaded a contact sheet from the pipeline, I saw problems I'd been blind to for hours. The hero section's crest was beautiful on desktop but crushed to illegibility on mobile. The book card grid had a one-pixel gap on the right edge where the math didn't quite work out. The checkout confirmation page — a page I'd barely looked at because I was so focused on the catalog — was still wearing the default theme like a guest at a costume party who forgot to dress up.
You don't see these things when you're close. You see them when you step back. The pipeline is a step back. The screenshot is the loupe. The eight-minute wait is the enforced distance between stimulus and evaluation — long enough for your pattern-matching brain to reset and see fresh.
Rena would have understood this immediately. She never judged her work through the viewfinder. Always through the contact sheet, always after the chemicals had done their work, always with distance.
The Negatives
The architecture is surprisingly simple once you squint at it right.
At the base, there's a test fixture — a WebApplicationFactory that boots the real ASP.NET application with a real database (SQLite, swapped in for PostgreSQL via a test-only configuration) and real seed data (books, authors, categories, a test user with items in their cart). The factory exposes the app on two hosts simultaneously: one for the test framework to call directly, and one on a real loopback port that Playwright can navigate to.
The browser contexts are hermetic. Every external request — Google Fonts, CDN scripts, analytics beacons — gets intercepted and aborted. What you're photographing is your application and nothing else. No flicker from a slow font load. No layout shift from a third-party script. Just your HTML, your CSS, your rendering.
The fixture also injects a stylesheet that kills all animations and transitions:
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
This is important. Screenshots capture a single instant. If your page has a fade-in animation, you're gambling on whether Playwright fires the shutter before or after the fade completes. Killing animations makes every screenshot deterministic — same input, same output, same pixels every time.
The tests themselves are parameterized. A route table defines the slugs, auth requirements, and any session state (like a populated cart). xUnit generates one test case per route per viewport. The whole suite runs in under a minute once the app is booted.
And then the pictures come out, and you look at them, and you either smile or you don't.
The Design
Four iterations. That's what it took to get the Dark Academia theme from concept to something that felt right.
The first iteration was bones. The color palette existed — --ink, --mahogany, --gilt, --parchment, --wine — but the components were still wearing their structural shapes. Book cards were rectangles. The hero section was a rectangle with text in it. Everything was a rectangle because CSS starts with rectangles and it takes active effort to make it anything else.
The second iteration added texture. The parchment background gained a radial vignette, warm at the center and darkening at the edges like a page held near a candle. A noise grain overlay, mixed at low opacity through a CSS filter, gave the surface the tooth of real paper. Horizontal ruled lines, drawn with a repeating linear gradient, suggested a ledger.
The third iteration was typography. Self-hosted WOFF2 files for EB Garamond and IM Fell English, loaded through @font-face declarations so there's no external font dependency. Garamond for body copy because Garamond has been making text look trustworthy since the sixteenth century. Fell English for headings because it looks like it was cut into a woodblock by someone who cared.
The fourth iteration was the polish that separates functional from finished. The book card hover state — a subtle lift with a deepening shadow. The receipt page styled as an actual receipt, complete with a wax seal SVG and a dotted border that says "tear here" without saying it. The login page treated not as a gate but as a welcome: warm, centered, with the brand crest above the form like a coat of arms above a doorway.
Each iteration was the same loop. Edit. Push. Wait. Look. Each contact sheet a progress photograph of the same building, gradually becoming itself.
The Filmstrip
Static screenshots tell you what the page looks like. They don't tell you what the page feels like.
When a visitor hovers over the book cover on the detail page, the cover tilts. A subtle 3D rotation — perspective(1100px) rotateY(-11deg) rotateX(4deg) — with a half-second cubic-bezier easing that starts fast and decelerates like a card being placed on a table. The shadow deepens. A wine-red glow bleeds into the penumbra. It's a small moment, less than a second, but it's the difference between a page that sits there and a page that responds.
How do you review an animation through a pipeline?
You make a filmstrip. Same principle as Rena's contact sheet, but for time instead of composition.
The camera fires once before the trigger (the baseline frame), then five more times at 120-millisecond intervals while the transition plays out. Six frames. 600 milliseconds. The frames are composed side-by-side on a dark canvas with time labels underneath — "0 ms", "120 ms", "240 ms", "360 ms", "480 ms", "600 ms" — rendered in JetBrains Mono because a monospaced font is the only honest way to label a time series.
The result is a single PNG that shows you the entire motion arc at a glance. Frame 1: cover upright, shadow resting. Frame 3: tilt deepening, the cubic-bezier at peak velocity. Frame 6: settled into the hover pose, shadow at full depth, wine glow visible.
One image. The whole movement. Contact sheet for animation.
There was a hitch, of course. There's always a hitch.
The fixture's animation-killing stylesheet — the one that makes static screenshots deterministic — also kills the transition you're trying to capture. And it does it aggressively: * { transition-duration: 0s !important } with a universal selector and !important is surprisingly hard to override through CSS specificity. I tried. Specificity escalation, class-level overrides, !important on top of !important. Chromium sided with the universal rule every time.
The fix was elegant in the way that the best fixes are — by being obvious once you see it. Don't fight the stylesheet. Remove it. The fixture's injected <style> element sits in the DOM with identifiable content. Find it, call style.remove(), and the real CSS transition cascade reasserts itself. The animation plays. The camera fires. The filmstrip captures the whole thing.
There was another hitch too. Playwright's HoverAsync() doesn't reliably fire :hover in headless Chromium. You can call it and nothing happens — the element sits there, untilted, unmoved, like a cat that heard you but chose not to respond. The workaround: add an .is-hovered class to the CSS rule alongside :hover, and toggle it via JavaScript. Deterministic. Reliable. And it means you can trigger the same visual state from a script that you'd get from a mouse cursor, which is all you ever wanted.
The Package
The filmstrip code started as a hundred and eighty lines in a test file. It ended as a single method call:
var png = await FilmstripCapture.CaptureAsync(
page,
cover,
() => cover.EvaluateAsync("el => el.classList.add('is-hovered')"));
Three arguments. The page. The element. The trigger. Everything else — the clipping with shadow padding, the frame capture loop, the composition onto a dark canvas, the time labels in a bundled font — lives in a NuGet package that any project can reference.
It's the same trajectory every tool follows if it's going to survive: prototype in place, validate, extract, generalize. The prototype taught me what the API needed to be. The extraction forced me to separate the mechanism (capture and compose) from the policy (which element, which trigger, how many frames). The generalization made it available to every other project with the same problem.
The Contact Sheet
I still think about Rena's closet darkroom. About the economy of it — the garden hose, the coat hook, the gaffer tape. She didn't need a studio. She needed a process that was fast enough to iterate and honest enough to show her what she'd actually made.
That's what this is. A darkroom in a pipeline. A loupe in a terminal. A contact sheet made of pixels instead of silver halide, but serving the same purpose: showing you your work with the distance you need to see it clearly.
The eight-minute cycle isn't a limitation. It's a feature. It's the enforced pause between creation and evaluation, the chemical bath between exposure and print. You can't tweak and judge simultaneously because the pipeline won't let you. You have to commit — literally, git commit — to what you've made and then wait to see what comes back.
And when the contact sheet downloads and you crack it open and every frame looks right — when the typography sits like it's been there for centuries and the shadows fall like candlelight and the animation unfolds across six frames like a flip book in a darkroom — that's the moment. That's Rena pressing the loupe to the contact sheet and finding the one frame that has whatever she's chasing.
You know it when you see it.
The tools described in this post are open source. The screenshot testing pattern lives in the Surfshack.Screenshots.Testing NuGet package. The ebook storefront is the Publishing Store. The eight-minute wait is, regrettably, real.