photosharingsite
Here is a site for a site were you can share your photos to other people so they can view and download in a stylistic and easy to view way.
Build prompt: a Pixieset-style client gallery on Liivo
Give this whole document to your AI coding agent as the brief. It is written to be handed over cold. Every number in the design section was measured from the live Pixieset product, and every platform rule was measured from a real deployment on Liivo. Do not treat any of them as a suggestion.
0. Your role
You are the technical lead and the builder. You are building a photo sharing product: a photographer uploads a shoot, designs a cover, and sends a client one link. The client opens a full-bleed cover, scrolls into a masonry grid, opens photos full screen, and downloads.
Rules for how you work:
- Build in vertical slices. Each slice is deployable and demoable on its own. Do not build the whole backend before any UI exists.
- Verify by running it, not by building it. A passing
next buildproves nothing about whether the gallery renders. Open the app in a browser and look at it. - Verify every user-visible change at two viewports: 375 x 812 and 1440 x 900. Check 768 wide as well when a layout has a middle state. Mobile is not a follow-up ticket. Most people open a photo gallery on a phone.
- Never write a secret into the repository or into a log line. Secrets live in the Liivo parameter store only.
- No em dashes in code, comments, docs or commit messages.
- Write down what you learn about the platform as you go, in
docs/LEARNINGS.md. You will otherwise pay for the same surprise twice.
1. The stack, and why
- Next.js App Router, TypeScript, one deployable app built from the repository root. The gallery and cover pages are server-rendered, which is what a link opened on a phone should be. API routes cover presign and downloads without a second server.
- PostgreSQL for collections, photos and zip jobs.
- MinIO (S3 compatible) for originals and derivatives, in two buckets.
sharpfor image derivatives, in-process.archiverfor gallery zips.- No ORM.
postgres(postgres.js) and plain SQL is enough here.
Package versions that are known to work together on the Liivo runner:
{
"engines": { "node": "22.x" },
"dependencies": {
"@aws-sdk/client-s3": "^3.1107.0",
"@aws-sdk/lib-storage": "^3.1108.0",
"@aws-sdk/s3-request-presigner": "^3.1107.0",
"archiver": "^8.0.0",
"next": "16.3.0",
"postgres": "^3.4.9",
"react": "19.2.8",
"react-dom": "19.2.8",
"sharp": "0.35.3"
}
}
2. Liivo platform constraints. Read this before you design anything
Liivo (liivo.ai, the Eyevinn Open Source Cloud) is reached through its MCP server. These facts were measured from inside a running pod. They are not negotiable and several of them determine the architecture.
Hard limits
| Limit | Value | Consequence |
| --- | --- | --- |
| Request body | 64 MiB exactly (67,108,864 bytes), rejected by the ingress with a 413 | Uploads cannot pass through the app. Transfer-Encoding: chunked is rejected too, so chunking is not a workaround. |
| Memory | 2 GiB cgroup limit, about 900 MB genuinely free | Read the cgroup, not os.totalmem(), which reports the host. |
| CPU | 2 cores, no quota, burstable | Do not size work against a guaranteed share. |
| Disk | /tmp writable, ephemeral | A restart or rebuild replaces the pod. |
| Request timeout | Unknown, bounded below at 119 seconds | Never design a long-running request against an assumed value. |
| Scaling | One warm pod, no autoscale, no scale to zero | A wedged connection pool wedges the whole product until a restart. |
Rules you must follow
- The app listens on
process.env.PORT. Never hardcode a port or a host. - Never set
APP_URL,AUTH_URLorPORTin the parameter store. They are auto-injected, and a parameter-store value overrides the correct one. - Never read
APP_URLfor anything user-facing. It is injected as the internal runner URL. A share link built from it resolves for nobody. Build public links from the*.apps.liivo.iohostname. - One deployable app per repository. The runner builds from the repository root and does not
parse
.osc.yaml.subPathcombined withconfigServicesilently loses every environment variable. - Pin a single Node major in
engines.node. That is the only lever. A range names no major and falls back to the image default of Node 24..nvmrcis ignored entirely by Liivo. - Parameters are injected at container start only. After adding a parameter you must
restart-my-app, or the running app behaves exactly as if the key is absent. - Always index
process.envwith a variable, neverprocess.env.LITERAL_NAME. A statically known key is inlined into the edge bundle at build time, freezing the build-time value instead of reading what the parameter store injects at runtime.
Storage traps
- There is no in-cluster path to MinIO from a My App. The internal cluster DNS endpoint refuses
on port 80 and blackholes on 9000. Use the public HTTPS endpoint (
S3_PUBLIC_ENDPOINT) for everything, server side included. It answers in about 214ms, so batch and parallelise derivative writes rather than assuming a cheap hop. - There is no IAM and no scoped access keys. Authentication is the MinIO instance's root credentials, workspace-wide across every bucket on that instance. Provision a dedicated MinIO instance for this app. Putting it on the shared workspace instance hands it the keys to everything else there.
create-storage-bucketprovisions nothing. It creates a bucket on an existing instance and defaults to the shared one.- There is no bucket-policy tool. Anonymous public read on the derivatives bucket is the
application's job: call
PutBucketPolicyat startup. - MinIO CORS preflight is answered by the ingress, not by MinIO, and the ingress echoes any
Origin, including a hostile one. CORS is not a security boundary here. The bucket policy is.
Deploy verification
Every rule here exists because a plausible reading of the platform's own signals said "live" while the old pod, a placeholder or the ingress was answering.
- The runner logs
Commit info: <sha> - <subject>immediately after checkout. This is the best deploy check there is. - Call the logs tool with no
leveland notail. BothCommit info:and[CONFIG] Loaded N environment variable(s)are emitted before npm install, so any tail long enough to look complete has already cut them off. ready: trueandawaitNewGeneration: trueare not proof. Keep polling untilbuildStatusisrunningandgenerationConfirmedis true, and treat any response mentioningbuilding-staleas not ready.- During a build the platform serves a placeholder that answers 200 on every route,
/adminincluded. Tell it from the real app by headers: the placeholder sends about 5 headers withcache-control: no-cache, the real app sendsetagandcontent-length. restart-my-appwithrebuild: falsestill pulls the latest main.
Fonts are a build dependency on Google, and they fail badly
next/font/google works: the builder has egress to fonts.googleapis.com. But it substitutes a
local face only when isDev is true. In a production build a fetch failure is a failed
build, not a degraded page, and the fetch timeout is undefined in production against 3000ms in
dev. Read a deploy that has stalled with no output for minutes as a possible font fetch, not only
as a slow build.
Secrets
Store credentials with create-service-secret and reference them as {{secrets.NAME}}. Do not
pass a secret value as a tool argument if you can avoid it: any value passed as an argument
persists verbatim in the session transcript. Several read tools (describe-service-instance,
get-parameter on a non-secret key) print live credentials in plaintext. Generate service
passwords yourself, alphanumeric only, 36 or more characters.
3. Step by step
Step 1. Repository skeleton, deployed empty
Goal: a Next.js app that builds and answers on Liivo. Nothing else.
npx create-next-app@latestwith TypeScript and the App Router, at the repository root.- Set
engines.nodeto"22.x"inpackage.json. - Add
next.config.ts:import type { NextConfig } from "next"; const nextConfig: NextConfig = { // next dev otherwise appends its own block to the project CLAUDE.md on every run. agentRules: false, }; export default nextConfig; - Add
app/api/healthz/route.tsreturning{ ok: true }. A health probe must assert on the value returned, never on the absence of a throw. - Push to a git remote the platform can reach. A workspace-managed Gitea repository needs no git credential at all: the runner authenticates natively and a private repo clones fine. Register a credential only for a genuinely external host such as GitHub.
- Create the app on Liivo, wait for it to be ready by the rules in section 2, and load the hostname in a browser.
Do not build more than this before the first deploy works. An over-scoped first deploy means you are debugging the platform and your own code at the same time.
Step 2. Provision the infrastructure
Before adding any infrastructure dependency, call list-available-services. The platform often
already provides what you were about to build. Service IDs follow {contributor}-{name} and cannot
be guessed.
Provision:
| Need | Service | serviceId |
| --- | --- | --- |
| Object storage | MinIO, a dedicated instance | minio-minio |
| Database | PostgreSQL, with publicAccess: false | birme-osc-postgresql |
| Config and secrets | App config service (the parameter store) | eyevinn-app-config-svc |
Postgres is created public and can only be closed afterwards. Close it.
Create two buckets on your dedicated MinIO instance: {app}-originals and {app}-derivatives.
Set these parameters in the store. Order matters: parameter store first, then the app.
S3_PUBLIC_ENDPOINT the full public origin, scheme and host included
S3_REGION us-east-1 is fine
S3_BUCKET_ORIGINALS
S3_BUCKET_DERIVATIVES
S3_ACCESS_KEY_ID
S3_SECRET_ACCESS_KEY secret: true
DATABASE_URL secret: true
ADMIN_PASSWORD secret: true
SESSION_SECRET secret: true
Do not set S3_ENDPOINT (the internal one is unreachable), and do not set APP_URL,
AUTH_URL or PORT.
Restart the app after setting parameters.
Step 3. Configuration and failure shape
Write lib/env.ts with required(name), optional(name) and missing(names) helpers that always
index process.env with a variable. Throw a typed ConfigError listing what is missing, and
render that as a readable page rather than a stack trace. The app must stay deployable before the
parameter store is populated: a missing key is a broken feature, not a boot failure.
Note that error class names are minified in the Next production build, so branch on a code
property you set yourself, never on err.constructor.name.
Step 4. Database and migrations
lib/db.ts holds a memoised postgres.js client on globalThis so hot reload does not open a new
pool per edit. lib/migrations.ts holds an ordered array of named migrations, each a list of
statements, applied inside a transaction with a migrations ledger table.
Schema:
create table collections (
id uuid primary key,
slug text not null unique,
title text not null,
event_date date,
photographer_name text,
theme text not null default 'light',
cover_photo_id uuid, -- deliberately no FK, see below
cover_style text not null default 'center',
fontset text not null default 'sans',
created_at timestamptz not null default now()
);
create table photos (
id uuid primary key,
collection_id uuid not null references collections (id) on delete cascade,
filename text not null,
content_type text not null,
original_key text not null,
byte_size bigint,
width integer,
height integer,
derivatives jsonb not null default '[]'::jsonb,
status text not null default 'pending',
created_at timestamptz not null default now()
);
create index photos_collection_idx on photos (collection_id, created_at);
cover_photo_id carries no foreign key on purpose: it would close a reference cycle with the
on-delete-cascade from photos back to collections. A cover that no longer resolves falls back to the
first photo at read time.
Run migrations from instrumentation.ts at startup, guarded by
process.env.NEXT_RUNTIME === "nodejs", wrapped in try/catch so a failure logs rather than kills
boot, and memoised behind the module so it is retried on first use. A startup side effect that
is not retried leaves the product silently broken until someone restarts the pod.
Step 5. Storage layer
lib/s3.ts. An S3Client memoised on globalThis, pointed at S3_PUBLIC_ENDPOINT, with:
forcePathStyle: true,
// Default WHEN_SUPPORTED adds x-amz-checksum-* to the signed headers of a presigned
// PUT, which the browser would then have to reproduce exactly.
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
Things that will bite you:
- An unread S3 response body never returns its socket to the agent, and 50 of those wedge every S3 call for the life of the pod. Destroy any body you do not read. This is a real defect that took a live pod down.
- The node handler defaults to
requestTimeout: 0, so nothing in the path has a ceiling of its own. Wrap every call in anAbortControllerplus an outer deadline race. - Classify connection errors (
ECONNREFUSED,ENOTFOUND,ETIMEDOUT,ECONNRESETand friends) into aStorageUnreachableErrorwhose message namesS3_PUBLIC_ENDPOINT. The most common misconfiguration is an endpoint without a scheme. - Derivatives get
Cache-Control: public, max-age=31536000, immutable. They never change. - For multipart uploads use 8 MiB parts with queue size 2. One part being filled and one in flight costs about 16 MiB regardless of archive size, and 8 MiB parts put the 10,000 part ceiling at 80 GB.
Add ensureDerivativesArePublicRead(), calling PutBucketPolicy for anonymous s3:GetObject on
the derivatives bucket, memoised and called from instrumentation.ts. Verify it: anonymous curl
must return 200 on a derivative and 403 on the matching original.
Step 6. Authentication
One photographer account. Do not run Keycloak for this.
lib/session.ts: an HMAC-SHA256 signed token of the form{expiresAt}.{signature}, signed withSESSION_SECRET, 24 hour TTL. Use Web Crypto only, nonode:crypto, because this module is imported by middleware which runs on the edge runtime.- Compare in constant time. Compare digests of the password rather than the passwords themselves, so neither the comparison time nor the length check says anything about the real password.
middleware.tscloses/admin,/admin/:path*,/api/adminand/api/admin/:path*by prefix, so a route added later is protected without anyone remembering to protect it. The sign-in page is the single exception.- With no
SESSION_SECRET, deny. An unconfigured deployment must have a closed admin, not an open one. - The client gallery is deliberately not in the matcher. A share link is the product:
/{slug}must never redirect, never require a cookie and never set one. - Cookie:
httpOnly,secure,sameSite: lax, path/.
Note: Next 16.3 has deprecated the middleware file convention. Check the version you are on and use the current convention.
Step 7. Admin surface
Routes:
/admin collection index, a grid of collection cards
/admin/sign-in password form
/admin/[slug] collection editor: photo grid, uploader, delete
/admin/[slug]/design cover designer with a live preview
The admin is always light, never themed by the collection's colour theme. Body font is a
neutral sans. Accent green is roughly #1ec9a0. Generous vertical whitespace, single narrow column
forms, never dense.
API routes under /api/admin/:
POST /api/admin/collections create
PATCH /api/admin/collections/[slug] rename, date, theme, fontset, cover style, cover photo
DELETE /api/admin/collections/[slug] delete, cascading to photos and objects
POST /api/admin/collections/[slug]/photos request a presigned PUT
DELETE /api/admin/collections/[slug]/photos/[photoId] delete one photo
POST /api/admin/photos/[photoId]/process build derivatives after upload lands
Validate every input server side. Do not use an object literal as a whitelist: it answers for
constructor and __proto__ and lets those through. Use a Map or a Set.
After a destructive mutation, the App Router's client cache will serve a stale list. Call
router.refresh(), and do not call history.pushState from inside a setState updater.
Step 8. Uploads and derivatives
Uploads go browser-direct to MinIO via presigned PUT. The file never passes through the application. This is forced twice over: by the 64 MiB ingress cap, and by the single warm pod that would otherwise serialize the whole site behind one client's transfer.
Flow:
- Browser posts filename, content type and byte size to
/api/admin/collections/[slug]/photos. - Server validates the type against a
Mapof accepted types (image/jpeg,image/png,image/webp,image/avif) and the size against a cap (50 MiB is a sane one), inserts aphotosrow withstatus: 'pending', and returns a presigned PUT valid for 15 minutes. - Bind the presigned PUT to its declared size and content type. A presigned URL that accepts any body is an open write endpoint on your bucket.
- Browser PUTs the file directly to MinIO, with a per-file progress bar.
- Browser calls
/api/admin/photos/[photoId]/process. The server reads the original back, builds the ladder, writes derivatives, and setsstatus: 'ready'.
Derivative ladder, in lib/derivatives.ts:
export const LADDER = [640, 1024, 2048, 3600];
sharp.cache(false); // libvips otherwise keeps decoded results in an arena that grows per request
- Produce rungs largest first, each from the previous rung rather than from the original, so a full-resolution decode happens once and only one decode is ever alive at a time.
- Two cores and a 2 GiB ceiling: do not parallelise this, and call it for one photo at a time.
sharpmetadata is pre-rotation. Orientation 5 to 8 means the displayed image turns, so swap width and height before choosing rungs. Call.rotate()on the first rung only.- Encode
jpeg({ quality: 82, mozjpeg: true }). - Re-encoding drops all metadata, which is sharp's default and takes EXIF GPS with it. That is what you want, since derivatives are public objects.
- Store the resulting
[{width, height, key}]array on the photo row as JSONB, and validate its shape on read.
sharp is confirmed working on the Liivo runner: libvips 8.18.3, SIMD on, a 640x480 resize in
22ms.
Step 9. Cover design, themes and typography
This is what makes it look like the real product. See section 4 for the measured numbers.
lib/cover.ts,lib/themes.ts,lib/fontsets.tshold the constants and type guards.- Build each cover style as its own small template taking
{coverImage, title, date, photographerName, logoUrl, ctaLabel}. They are separate layouts, not one component with an alignment flag. Text transform, frame, logo placement and button treatment all vary between them. - Load fonts with
next/font/google, one pair per typography set. Preload only the default pair, so runtime cost per page is unchanged and the cost is entirely at build time. - The designer page shows a live preview with a desktop/mobile toggle, and pickers for cover style, cover photo, typography set and colour theme.
- The picker must not pull full-size images. Use the 640 rung for thumbnails. A picker that pulls 35 MB on a phone is a real defect that shipped once here.
CSS notes that cost time:
- CSS
column-countis not masonry, and a percentage intoporheightresolves against the wrong axis. - A
paddingshorthand inside a media or container query flattens vertical padding. justify-content: centeron a fixed-height box overflows off both ends.- A container cannot be styled by its own container query.
composesresolves exactly one level in this toolchain and fails silently beyond that.
Step 10. The client gallery
Routes:
/{collection-slug}/ cover, then masonry gallery
/{collection-slug}/?pid={photoId} lightbox deep link
Cover. Full viewport, full-bleed cover photo, no navigation at all. The gallery does not begin until you scroll. Cover height is set as an inline style equal to viewport height for every style except Classic, which is viewport height minus the navbar.
Navbar. Sticky, 78px tall, white, high z-index, sitting directly under the cover. Left:
collection name in the heading face at 16.2px with 1.458px letter-spacing in #1e1e1e, photographer
name beneath it in about 9px uppercase grey. Right on desktop: favorites, download, share,
slideshow, inline. On mobile: the collection name truncates with an ellipsis and the actions
collapse to a few icons plus a kebab holding Download, Share and Slideshow.
Masonry. This is the single most important detail after the typography.
- Desktop 1440: 4 columns, 347px bricks, 3px padding per brick so a 6px visible gutter.
- 768: 3 columns.
- Mobile 375: 2 columns, edge to edge, no page padding.
- Compute the placement on the server so the markup that first paints is already the final layout. Nothing is measured in the browser and nothing moves after hydration.
- Make every length a multiple of the column width expressed as a percentage, so one placement is correct at every width and only the column count changes. Give each brick its column and offset for each of the three breakpoints and let CSS pick the set.
- Place each brick in the currently shortest column, with half a pixel of tolerance so a near-tie keeps the leftmost column.
Lightbox. Not a modal overlay. A full white page. Back arrow top-left, actions top-right,
photo centered, filename in small grey text beneath. Deep-linkable via ?pid=.
Beware: loading="lazy" is ignored inside an overflow container in Chromium.
Favorites. Clicking the heart as an anonymous visitor opens a centered white modal over a dark
scrim: heading "Favorites", a line of copy, a single Your email input, a black Sign In button.
No password. The email is the identity. Favorites are recorded against that email so the
photographer knows who picked what.
Step 11. Download
Single photo download is a straightforward presigned GET.
The gallery zip is the hard one, and three platform constraints together leave exactly one shape:
- It cannot be built inside the request, because the request timeout is unmeasured and a 300 photo wedding exceeds any plausible value of it.
- There is no background worker available. The platform's job service is Python only, requires a cron schedule, and lives in a separate repository, which collides with one deployable app per repository.
- One warm pod serves everything, so the build shares a process with every page request.
Therefore: build the zip asynchronously in-process. One endpoint both starts a build and reports its progress, the client polls it, and a presigned URL to the finished archive is handed back when it is ready.
- Archives are written to the private originals bucket under
zips/{collectionId}/{size}-{attempt}.zip. - A
zip_jobstable with a unique index on(collection_id, size)means one build per collection and size however many visitors ask for it. - A fingerprint over the ready photo ids and filenames is what makes a finished zip reusable. It changes when the photo set changes, which is the only thing that invalidates the object.
- Scope the object key to the attempt that writes it, so a superseded build cannot overwrite its
replacement's object. That makes the key a result rather than a plan, so
object_keyis nullable and a claim names no object until one exists. The tradeoff is that it turns an overwrite into a leak: clean up superseded keys explicitly, and abort abandoned multipart uploads. - Heartbeat the claim, and derive the liveness timeout from the deadline it polices, not from a round number.
- Cap concurrent builds at 2. Over the cap a claim waits its turn rather than being refused: the row already says running, and reporting a build that is not happening would be a lie.
- Two sizes are enough:
web(the 2048 rung, which Pixieset calls Web Size) andoriginal.
Measured, and the reason building in-process is acceptable: a 300 photo, 5.86 GiB archive peaks around 348 MB RSS against roughly 900 MB free. The first build in a fresh process costs a one-time warm-up of 146 to 168 MB, after which the marginal cost is 1 to 59 MB and does not grow with archive size. Under two simultaneous 8 GB builds the gallery still served p50 17ms.
Two gotchas: archiver 8 has no archiver("zip", opts) factory, use the ZipArchive class. And a
SIGTERM handler in next start cannot finish async work, so do not plan a graceful drain.
Step 12. Deploy and verify
- Push. Watch for
Commit info: <sha>in the logs with noleveland notail. - Poll until
buildStatusisrunningandgenerationConfirmedis true. - Confirm you are talking to the real app and not the placeholder, by headers.
- Confirm
[CONFIG] Loaded N environment variable(s)reports the count you expect. - Walk the whole loop on the live host: sign in, create a collection, upload five photos, pick a cover style, open the share link in a private window, scroll, open the lightbox, download.
- Do step 5 again at 375 wide.
4. Design specification, measured
All values below were read from the live Pixieset product with getComputedStyle and
getBoundingClientRect at 1440 x 900 and 375 x 812. They are not designed, they are measured.
Reproduce them.
The six things that actually make it feel right
A feature list will not reproduce the look. These do most of the work.
- The photos are the only coloured thing on the page. Everything else is white, near-black and thin grey rules.
- Wide letter-spacing on small uppercase text. That single detail carries most of the look.
- Very tight grid gutters, 6px, so the grid reads as one field of images rather than a set of tiles.
- No visible borders, cards or shadows around photos.
- The cover is a full viewport with no navigation at all.
- Generous vertical whitespace in the admin. Forms are a single narrow column, never dense.
Typography sets
Six sets exist. Five are buildable: the sixth, "Timeless", uses MADE Mirage, a commercial font, so either license a substitute or leave it out.
| Set | Headings | Body | | --- | --- | --- | | Sans | Raleway | Lato | | Serif | Cormorant | Spectral | | Modern | Tenor Sans | Red Hat Display | | Bold | Syne | Red Hat Text | | Subtle | Montserrat | Quattrocento |
Root 16px, breakpoints 768 and 992. The cover style does not change the type scale.
Cover title. Cells are px size / letter-spacing px at that viewport.
| Set | Family / weight | Transform | line-height | 375 | 768 | 1440 | | --- | --- | --- | --- | --- | --- | --- | | Sans | Raleway 700 | uppercase | 1.1 | 39 / 1.95 | 46.8 / 2.34 | 52 / 2.6 | | Serif | Cormorant 300 | none | 1 | 48 / -0.48 | 57.6 / -0.576 | 64 / -0.64 | | Modern | Tenor Sans 400 | none | 1 | 42 / -1.26 | 50.4 / -1.512 | 56 / -1.68 | | Bold | Syne 700 | uppercase | 1 | 36 | 43.2 | 48 | | Subtle | Montserrat 300 | uppercase | 1.3 | 34.5 / 0.69 | 41.4 / 0.828 | 46 / 0.92 |
Cover date. All sets uppercase, line-height 1.8.
| Set | Family / weight | 375 | 768 | 1440 | | --- | --- | --- | --- | --- | | Sans | Lato 400, .15em | 11.2 / 1.68 | 12.6 / 1.89 | 14 / 2.1 | | Serif | Spectral 300, .25em | 11.2 / 2.8 | 12.6 / 3.15 | 14 / 3.5 | | Modern | Red Hat Display 500, .25em | 10.8 / 2.7 | 12.15 / 3.0375 | 13.5 / 3.375 | | Bold | Red Hat Text 400, .06em | 12 / 0.72 | 13.5 / 0.81 | 15 / 0.9 | | Subtle | Quattrocento 400, .25em | 10.4 / 2.6 | 11.7 / 2.925 | 13 / 3.25 |
CTA label. No breakpoints at any width, line-height 16px.
| Set | Family / weight | Size | letter-spacing | Transform | | --- | --- | --- | --- | --- | | Sans | Lato 500 | 11px | 1.65px | uppercase | | Serif | Spectral 500 | 11px | 1.65px | uppercase | | Modern | Red Hat Display 500 | 13px | 1.04px | none | | Bold | Syne 700 | 11px | 1.98px | uppercase | | Subtle | Montserrat 600 | 11px | 1.65px | uppercase |
Photographer credit. No breakpoints, uppercase, line-height 1.5, no opacity rule in any set.
| Set | Family / weight | Size | letter-spacing | | --- | --- | --- | --- | | Sans | Lato 500 | 10px | 2px | | Serif | Spectral 400 | 10px | 1.8px | | Modern | Red Hat Display 500 | 10px | 2.5px | | Bold | Red Hat Text 400 | 13px | 1.3px | | Subtle | Quattrocento 400 | 11px | 1.32px |
Colour themes
Nine themes, each a base background, a secondary surface and an accent. The theme also drives
--theme-button-background (the accent), --theme-button-text (#ffffff) and
--theme-opacity-cover-tint (the cover scrim), so the theme is load-bearing on cover styles that
put the photo on the background rather than under text.
| Theme | Base | Surface | Accent |
| --- | --- | --- | --- |
| Light | #ffffff | #f5f5f5 | #333333 |
| Gold | #fffefa | #fcf8f2 | #9e8962 |
| Rose | #fbf8f7 | #f8f3f2 | #9e7977 |
| Terracotta | #fbf7f4 | #f3ece7 | #9e765d |
| Sand | #f5f2f0 | #e9e4e2 | #9e8b7d |
| Olive | #f6f6f3 | #edece8 | #9d9e7e |
| Agave | #f5f6f5 | #edf0ed | #869e94 |
| Sea | #fafafa | #eaecee | #93939f |
| Dark | #1e1e1e | #282828 | #4d4d4d |
The three button treatments
Defined per cover style, not per theme.
- Boxed outline:
height:40px; padding:0 24px; border:1px solid currentColor; background:transparent. Used by Center, Left, Stripe, Divider. - Filled:
height:40px; padding:0 24px; color:var(--theme-button-text); background:var(--theme-button-background), no border. Used by Novel, Vintage, Journal, Stamp. - Underlined text:
display:inline-block; padding-bottom:6px; border-bottom:1px solid currentColor; background:transparent. Used by Frame, Outline.
The button width is intrinsic to the label and the typeface, not fixed. It measures 147.64px in Lato. Do not hardcode a width, and do not assert on one.
Cover styles
Ship as many as you have appetite for. Start with Center, then Left, then Frame, then Outline. Each is its own template.
Center. The baseline. Full-bleed photo, text overlaid and centred.
| Property | 1440 x 900 | 375 x 812 |
| --- | --- | --- |
| Cover height | 900 (100vh) | 812 |
| Scrim | rgba(0,0,0,0.2) full height, from the theme tint | same |
| Bottom gradient | on the image container ::after: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(0,0,0,0) 5%, rgba(0,0,0,0.08) 45%, rgba(0,0,0,0.5) 90%, rgba(0,0,0,0.7) 100%), height 300px | 300px |
| Text block | position:absolute; top:50%; transform:translateY(-50%); text-align:center; color:#fff | same |
| Title width | 85%, min-width:980px | 90% |
| Date | margin-top:15px | same |
| Button wrapper | margin:40px auto 20px | same |
| Credit block | position:absolute; bottom:4%; padding:0 15px; text-align:center | same |
Left. Text block bottom-left rather than centred. The CTA moves to the bottom-right on
desktop and back under the title on mobile, implemented as two separate DOM elements toggled at
768, not one repositioned element. The flat scrim is replaced by two directional gradients: a top
one linear-gradient(rgba(0,0,0,0.2), rgba(0,0,0,0)) at 18% height, and a bottom one
linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.4)) at 40% height with bottom:-0.5px. Text block
padding:0 0 60px 60px; max-width:calc(100% - 300px) on desktop, padding:0 0 40px 40px; width:75% on mobile. Business block padding:60px 0 0 40px, 20px top padding below 768.
Frame. The photo is a background-image with background-position acting as the focal point,
not an <img>. Scrim rgba(0,0,0,0.2). An inset hairline rectangle: position:absolute; inset:20px; border:1px solid rgba(255,255,255,0.7), switching to inset:10px below 768. Business
block pinned top with margin-top:60px, text block pinned bottom with margin-bottom:60px, both
centred. CTA wrapper margin:60px 0 0, underlined-text treatment measuring 97.6 x 23.
Outline. A fixed-width bordered box floating over a full-bleed photo, containing a three-row
grid 1fr auto 1fr so the logo pins to the top, the title sits dead centre and the CTA pins to the
bottom. Box is width:35vw clamped by min-width:510px, margin:40px, padding:60px 4vw,
border:1px solid currentColor. Logo max-width:250px; max-height:80px; object-fit:contain,
dropping to 200 x 60 at 992. The date sits above the title. Box breakpoints are 481 and
1200, not the usual 768 and 992.
Novel. Not a photo with text on it. A two-panel split: text on the theme background inside a
hairline box, photo inset inside a 60px border of the secondary surface colour. No scrim, no white
text. The split happens at 1200px, so both 768 and 1024 render stacked. Content box margin:60px; padding:60px 3vw; border:1px solid var(--theme-border), flex column centred. Note that the mobile
minmax(50%, auto) row in the original CSS is inert against a min-height-only parent: use
flex: 1 1 auto and accept a content-driven split.
Classic. Center with everything removed. One rule: .collection-cover { position: relative; }.
A single img using object-position as the focal point. No title, date, button, logo, scrim,
gradient or frame. Cover height is viewport height minus the navbar: 822px at 1440 x 900. The
collection title appears only in the navbar.
Divider, Vintage, Stripe, Journal, Stamp are further styles from the real product. Their specs were read from shipped CSS rather than observed in the wild, so if you build them, compare the result against a real gallery before calling it done.
Mobile versus desktop
| | Mobile 375 | Desktop 1440 |
| --- | --- | --- |
| Grid | 2 columns, edge to edge, no page padding | 4 columns, 347px bricks, 6px gutters |
| Cover title | 39px, 1.95px letter-spacing, wraps to two lines | 52px, 2.6px letter-spacing |
| Cover button | height:40px; padding:0 24px, unchanged | same, width intrinsic |
| Navbar actions | a few icons plus a kebab | all actions inline |
| Collection name | truncated with an ellipsis | full |
Layouts are not scaled down. Navigation, actions and grids change shape. Touch targets are at least
44px. Anything that only appears on hover needs a tap equivalent. No horizontal page scroll at any
width, and note that overflow-x: hidden makes a horizontal-scroll check meaningless, so do not
"fix" it that way.
5. Scope
In, thin but end to end:
- Photographer signs in, creates a collection, uploads photos.
- Picks cover design, typography set and colour theme.
- Shares a link, optionally protected by a collection password.
- Client sees the cover, a masonry gallery, a lightbox, favorites gated by email, and download.
Out:
- Print store, cart, price sheets, checkout.
- Studio manager, website builder, email campaigns.
- Lightroom plugin, mobile app generation, RAW delivery, video.
- Multiple photographer accounts. This is a single photographer.
- Watermarking, auto expiry, per-set download rules, favorite lists with selection limits.
Each half is useless alone. An admin with no gallery cannot be shown to anyone, and a gallery with no admin has no way to get photos into it. Ship the thin complete loop first.
6. Acceptance criteria
Do not call this done until all of these pass on the deployed host, at both 375 x 812 and 1440 x 900.
/adminredirects to the sign-in page when signed out, and/api/admin/*returns a JSON 401.- With
SESSION_SECRETunset,/adminis closed, not open. - A share link
/{slug}never redirects, never requires a cookie and never sets one. - Uploading a 40 MB JPEG succeeds and never touches the application process.
- Uploading a
.txtrenamed to.jpgis rejected server side. - A presigned PUT rejects a body of a different size or content type than it was issued for.
- Anonymous
curlreturns 200 on a derivative object and 403 on the matching original. - The cover title at 1440 measures exactly the size and letter-spacing in the table for the
selected typography set, read with
getComputedStyle. - The masonry has 4 columns at 1440, 3 at 768 and 2 at 375, with a 6px gutter, and nothing moves after hydration. Verify by comparing the server-rendered HTML positions with the post-hydration ones.
- Every image in the grid has
naturalWidth > 0. A broken image still occupies its brick and still passes a naive layout check. - A gallery zip of 200 photos completes, and the page stays responsive while it builds.
- Two visitors asking for the same zip at the same time produce one build and two working download links.
- Deleting a collection removes its rows and its objects, and the admin list updates without a manual refresh.
- No horizontal page scroll at 320, 375, 768, 1024 or 1440.
- Every action reachable on hover is reachable by tap.
7. Testing notes that will save you a day
- Start any local S3 stand-in BEFORE the app, and assert
naturalWidth > 0, not just that animgelement exists. - Test against a blackhole, not a refusal, when a timeout is the thing you are reasoning about. What matters for connection-failure tests is whether the peer closes, not what it sends. postgres.js hangs forever against a peer that accepts and then closes.
- A fixture too small to have a middle cannot test anything about a build's middle.
- A reproduction test asserts the bug, so it fails when the fix lands. Convert it, do not delete it.
- Visibility and accessible name are different questions. Assert both.
- Measure text with
Range.selectNodeContents, not the element box. elementFromPointat the exact viewport edge returns null.- A one-sided coordinate comparison is not an overlap test.
- A spinner state is a disabled state, so it drops focus to the body.
next devandnext buildin the same directory corrupt each other's output. Two agents sharing one checkout, one.nextor one dev database will clobber each other.lsof -ti:PORTlists clients as well as listeners.- A scripted scroll cannot test a scroll lock.
- To confirm a fix is the fix, build a second copy with only the one changed file reverted.