This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
Hanging a picture wall is a measuring problem before it’s a taste problem. The gallery wall arranger takes a pile of photos, frames each one, and lays them out to scale against a wall you describe. Drag the frames where you want them, untangle any overlaps, and print a hang sheet with the exact measurements before a single nail goes in. This is how it’s built, and why almost all of it is a pure function.
The parts worth getting right — which frame suits a photo, where every frame lands on the wall, what happens when you change one — are all plain functions with no React in sight. There are three small modules: frames knows the standard sizes and picks one, arrange packs frames onto the wall, and state is a reducer over the uploaded photos plus a selector that turns the whole thing into a layout. The component is left holding the boring end: a file input, some number fields, and the buttons that dispatch into the reducer.
That split is the reason the tests read like a spec of the behaviour rather than a tour of the DOM. The auto-framer, the packing, the overflow rules, and every reducer action are checked directly, and the component test just confirms the wiring by seeding a state and poking the controls.
Every photo arrives with one number that matters: its width over height. Orientation falls straight out of it — wider than tall goes landscape, everything else (squares included) goes portrait. The size is the standard frame whose aspect ratio, in that orientation, sits closest to the photo’s.
4×6, 8×10, 16×20 …). Portrait keeps the short side as the width; landscape swaps it. One list serves both orientations.8×10 and 16×20 are both 4:5 — so ties break toward the medium 8×10 default. A standard photo lands on a sensible frame instead of the biggest match.The layout is a shelf pack, the way most real gallery walls actually read: frames fill a row left to right, and when the next one would spill past the wall width the row wraps. Each row is centered horizontally, and within a row every frame is centered vertically, so a short frame sits level with a tall neighbour instead of dropping to the floor.
Fit is reported, not enforced. The arranger hands back a contentHeight and an overflows flag — true when the stacked rows are taller than the wall, or when one frame is simply wider than the wall — and the UI turns that into a plain warning. It never silently shrinks a frame to make the math work, because the frames are real sizes you’re going to buy.
Frames are sold in inches, so the whole core reasons in inches and nothing else. The unit toggle lives entirely at the input boundary: a centimetre you type is converted to inches before it reaches state, and an inch from state is converted back only to fill the field. The layout math never sees a unit at all, which is exactly why it stays simple — it’s unit-agnostic arithmetic that happens to run in inches.
The preview is one SVG whose viewBox is the wall’s physical size in inches. A frame that’s eight inches wide is eight units wide; there is no pixel conversion anywhere, and the browser scales the whole wall to whatever width the column gives it. Each frame is a white mat with a dark border and the photo cropped to fill (preserveAspectRatio="xMidYMid slice", the SVG spelling of object-fit cover). The preview window is a fixed size no matter the wall or the zoom — the wall is fit and centred inside it, and zooming (type a percentage or use the buttons) scales the content past the edges so the window scrolls to pan. Because the SVG has no fixed pixel sizes it stays razor-sharp at any zoom, a little minimap shows which slice of the wall you’re looking at, and the drag math divides the pointer delta by the one uniform fit-scale so a frame stays under the cursor even when the window letterboxes the wall.
The stage has two accessibility shapes. A static preview is a single labelled img region — “Gallery wall preview: 4 frames on a 96 by 60 inch wall” — so a screen reader hears one summary. Once it’s interactive each frame becomes a named button, so the SVG switches from role="img" to role="group"— an image with focusable children is a nested-interactive axe violation. The rest of the controls carry their weight too: real labels on every field, each photo’s controls in a fieldset, and warnings in a live region with an icon so they aren’t colour alone.
Auto layout is only the starting point. Every frame can be dragged anywhere on the wall with a pointer, and the same frame is a focusable button you can move with the arrow keys (hold Shift for a five-inch step instead of one). Dragging with a mouse and nudging with the keyboard both funnel through one move-image action, so there’s a single clamp keeping frames on the wall and a single place the position changes.
Two quiet decisions make it feel right. The pixel delta from a pointer drag is converted into wall inches through a tiny pure helper (clientDeltaToWall) using the rendered size of the SVG, so a frame tracks the cursor at any zoom. And the first time you drag any frame, every frame is frozen at its current auto spot — otherwise moving one would let the shelf pack reflow all the others out from under you.
A gallery wall where two frames occupy the same nail is not a plan you can hang. So overlap isn’t a warning you can ignore — it blocks the save. A pure findOverlaps does an all-pairs rectangle intersection (edges that merely touch don’t count, so a tidy flush layout stays valid), findOutOfBounds catches frames dragged off the wall, and computeValidation folds both into an invalidIds list and a single canSave boolean.
The UI reads straight off that: offending frames turn red in the preview, a warning pops over the wall, and the Save button is disabled until it’s clean. One role="alert" so a screen reader is told once, not per frame. Auto-arrange is always one click away to untangle everything back into a valid layout.
The auto layout comes in two shapes. Rows is the original shelf pack; masonry is a true staggered wall — fixed-width columns with each next frame dropped into the shortest column, so the rows never line up and it reads like a real salon hang. Both are pure functions over the same input, and the layout mode just picks which one seeds the un-dragged frames, so dragging and validation work identically on top of either.
Knowing where a frame sits on screen isn’t the same as knowing where to put the nail. computeHangSheet turns each placement into the numbers you actually measure on the wall: the hook sits at the frame’s top-centre, dropped a little for a taut wire, and the sheet gives its distance from the left edge and from the top edge. It renders as a plain table you can print (the values follow the unit toggle), so the on-screen plan becomes a tape-measure checklist.
The first version serialised to localStorage: one slot, no name, and photos held as object URLs that die on reload. A restored wall kept its frames and measurements but wanted every image re-added. Walls now live in S3 instead, one folder per wall, scoped to the signed-in user:
gallery-walls/{userSegment}/{wallId}/manifest.json
gallery-walls/{userSegment}/{wallId}/images/{id}.webpNo database. A person saves a handful of walls, so a table plus a migration buys nothing that a key prefix doesn’t already give you: per-user isolation is the path, and deleting a wall bulk-removes its photos. Only images still held as blob: or data: URLs get uploaded, so re-saving an unchanged wall re-uploads nothing.
Saving worked on the first try. Loading took four separate bugs, and each one produced the identical symptom — a wall with frames and no photos — which is what made it interesting.
Screenshot 2025-11-18 at 10.40.57 AM.png carries spaces and a narrow no-break space (U+202F), and those don’t survive a round trip as a field name. The server got a subtly different string, failed to match the upload back to its image, and left the dead blob: URL in place. The photo was sitting in S3 the whole time. Files are now paired to images by position against an explicit imageIds list — never by name.imageIds field didn’t work at first, because validateBody replaces the body with the parsed result and Zod strips keys it doesn’t know about. The field has to be declared in the schema or it silently vanishes between the middleware and the controller.img-src didn’t list it, so the browser refused all of them. Frames drew, photos didn’t. The policy now reads NEXT_PUBLIC_MEDIA_ORIGIN, and lives in lib/csp.ts so the part that varies by environment is unit tested — including that a media origin can never widen script-src.CDN_BASE_URL had no fallback. It’s optional, and the URL was built by interpolation, so when it was unset every stored src began with the literal string undefined. It now falls back to the bucket’s own URL.The debugging lesson was about evidence, not any of these causes. The thing that cracked it was noticing the browser made no image requests at all. A blocked request still appears in the network panel; zero requests means the element never had a URL to fetch. That ruled out CSP and permissions in one step and pointed straight at the stored data — where the manifest still held blob:http://localhost:3000/…. Reading the actual saved object beat every theory I had about it.
The deeper mistake was treating a filename-derived id as safe to use as a key, a field name, and a URL segment. It is none of those things: it is arbitrary text from whatever a person named a file. The id is still useful for identity — re-adding the same photo reuses it — but it now gets flattened to [a-zA-Z0-9.-_] before it becomes an S3 key, so the resulting URL needs no escaping to be fetchable. Deterministically, so the delete path rebuilds exactly the same key.
A smaller bug in the same family: the floating settings panel could pop out but not dock again. Its header is both the drag handle and the home of the Dock button, so pressing that button started a drag and called setPointerCapture — and capture retargets the pointer, which swallowed the button’s own click. Docked, the header has no drag handlers at all, which is exactly why only one direction broke. Presses that land on a control no longer start a drag.
Worth noting what didn’t catch it: the unit tests passed, because jsdom doesn’t implement pointer-capture retargeting. It could reproduce the unwanted drag but never the swallowed click. Some bugs only exist in a real browser, and that is a reason to open one, not a reason to trust the green checkmark.
One trap worth flagging: the packing module started life as layout.ts co-located under app/gallery-wall/, and Next promptly treated it as a route layout and demanded a default export. The fix was to move the pure modules into a private _lib/ folder (the underscore opts the whole folder out of routing) and rename the file to arrange.ts. Filenames under app/ are never just filenames.
The throughline across drag, overlap, masonry, the hang sheet, and save is the same as the first version: every hard part is a pure function you can test, and the component is just the accessible wiring around them. What the save work added to that is a boundary lesson — the pure core was never the thing that broke. Every one of those four bugs lived at an edge: a multipart field name, a schema that strips unknown keys, a response header, an unset environment variable. Purity buys you a lot, and none of it applies where your code hands something to a system it doesn’t control.
Left for later: mat and frame-colour choices, snapping to a shared baseline while dragging, and a migration for walls saved before the upload fix — their manifests still point at blob: URLs that will never resolve, and the honest repair is to re-save them.
Update — August 10, 2026
Everything above describes arranging a wall. It all assumed you would do it in one sitting, which is not how anyone hangs pictures.
Walls are saved now — named, stored in S3, with open, rename and delete. That turned this from a toy into something you can put down and pick up, and it is the single change that most altered what the page is for.
The sizing default was backwards. Photos were framed by picking a size the resolution comfortably supported, which sounds responsible and produced walls of small prints. People hanging a gallery wall want the biggest print the photo can carry. It now defaults to 11x14 and steps down only when the resolution genuinely cannot hold it. Same information, opposite default, much better result — a good reminder that a safe default and a useful one are not the same thing.
The pairing bug is the one I learned most from. Uploaded photos were being matched to their images by identity in a way that broke when uploads resolved out of order, so you would arrange a wall and find the wrong picture in the frame. Pairing by position fixed it. It took real debugging to see, because every individual piece looked correct and only the combination was wrong.
Alongside those: print costs so the wall has a price attached, an aesthetic arrange option, pan with a draggable minimap for walls bigger than the viewport, and a fix for the floating settings panel refusing to dock again once undocked.