References that arrive already named.
I built this out of my own screenshots folder. Pin an image to a day of the week and it comes back as a Polaroid carrying five to ten design terms read off the image itself โ Brutalist, Modular Grid, Muted Earth Tones, Light Trails, Low Key Lighting โ the vocabulary you'd actually type into a search or hand to a collaborator. The tags on the demo board are real output, not mock-ups.
public/moodboard/board.jpg and swap this box for
<img src="/moodboard/board.jpg" alt="โฆ"> plus a
<figcaption>.People who collect visual references faster than they can organise them. Designers by trade, but also anyone who screenshots a layout, a poster, a photograph or a colour combination because it looked right.
<blockquote>"โฆ"<cite>โ Friend, on first use</cite></blockquote>
"My references are disorganised" points at folders, and folders are not what's broken.
Two mandates fall out of that. The words have to be written without the collector writing them, at save time, or the save doesn't happen. And they have to be the vocabulary designers actually search and brief in โ otherwise I've swapped an unsearchable folder for an unsearchable caption.
There's a second-order effect I didn't expect to care about as much as I do. If the tags are in real design language, reading your own board teaches you the language. The archive stops being storage and starts being the thing that closes the gap between noticing and naming โ which was the root cause in the first place.
The smallest thing that tests both mandates: drop an image on a day, get design vocabulary back. Nothing else in v1 does anything else.
Weeks instead of collections is the decision I'd defend hardest. A collection demands an answer at save time โ "what is this, and where does it go?" โ which is precisely the friction that killed tagging in the first place. A week demands nothing. Time files it for you, and "the week I was looking at brutalist posters" turns out to be a surprisingly good index into your own memory.
Cut from v1: cross-week search, tag normalisation, accounts, sharing, collections, and a mobile share-sheet. Search being absent from a project about searchability is deliberate โ searching a board this size tells you nothing, and it would be broken on arrival by the duplicate-vocabulary problem in "What I'd change" below. Extraction had to be proven first, then cleaned up, and only then searched.
Automatic tagging at upload over tagging it yourself
Friction at save time is the root cause, so the save has to stay a single gesture.
Cost: you get tags you didn't choose, and some will be wrong โ which is why there's a Try analysing again, and why tag acceptance is a metric below.
Weekly boards over collections you name
No decision is required at the moment of saving. Time does the filing.
Cost: no thematic grouping, and finding something across many weeks is genuinely unsolved until search exists.
Five to ten terms over a written description
"Describe this image" gets you prose about what's in the picture. You can't search a paragraph, you can't paste it into a brief, and it isn't how designers hold references in their heads.
Cost: terms lose nuance a sentence could carry. Ordering them most-to-least defining claws some of it back.
Five named vocabulary axes over an open-ended ask
Style, layout, typography, colour, material. Without them the model fixates on colour and misses structure entirely โ ten shades of beige and nothing about the grid. Naming the axes forces coverage across them.
Cost: the axes are my taxonomy, and they bias what the archive can ever say about an image.
A pinned model version over a -latest alias
The failure mode of a floating alias is nasty here: when it's retired or overloaded, every upload silently drops to placeholder tags. Nothing errors, the app keeps working, and the only symptom is that quality quietly decays โ in an archive you won't re-read for months. A pinned version fails loudly instead.
Cost: manual upgrades, and I'm pinned to a model that will eventually be worse than what's available.
Fallback terms, clearly labelled over an error state
When analysis fails, the image still pins, carrying what's knowable without a
model โ aspect ratio, resolution band, file type, size โ ending in
Awaiting Analysis. The UI says "These are placeholder terms read
from the file, not AI analysis", and the database records them as a fallback,
not as analysis.
Cost: a row in your board that's doing nothing for you. Better than a broken save, and far better than a lie.
A seeded public demo over a keyless one
Anyone can clone and run this with no environment variables at all. But a visitor
with no key would see Landscape Format ยท PNG Source ยท Medium File ยท
Awaiting Analysis โ a correct answer, honestly labelled, that demonstrates
nothing whatsoever about what the project is for.
Cost: the public demo is read-only, so you can see the output but not the moment it arrives. That's what the walkthrough video is for.
โTime to find a reference
From "I need that beige thing" to having it open. Baseline: scrolling a folder is over a minute and frequently ends in failure. Target: under ten seconds via a tag. This is the root cause measured directly โ and it can't be measured until search ships, which is the strongest argument for shipping search next.
โRetrieval rate
Share of saved references that are ever opened again. This is the honest test of whether a reference library is a library or a graveyard. For my own camera roll the answer is approximately zero, which is why the project exists.
โTag acceptance
Share of returned terms the collector agrees are actually visible in the image, collected with a one-tap dismissal on any tag. Below roughly 80% the vocabulary reads as noise, people stop trusting the board, and every downstream metric dies with it.
โAxis coverageGuardrail
Bucket each image's terms into the five axes. An image whose tags are all colour words is a failed analysis even if every term is correct โ it's the exact failure the axes were written to prevent, and accuracy alone won't catch it.
โTime to saveGuardrail
Must stay about a second. The instant analysis blocks the save, I've reintroduced the friction that caused the problem โ analysis is allowed to take 25 seconds, the save is not allowed to wait for it.
โVocabulary growth
Distinct design terms a collector uses in their own searches and briefs over months. This is the ambitious one โ the claim that reading your own tags teaches you the language. A workable proxy: share of searches typed in terms the person first met on their own board. Hardest to measure, and the one I'd most want.
public/moodboard/walkthrough.mp4,
delete this box, and uncomment the tag below.
The demo board is pre-populated with real analysis โ those tags came back from Gemini during an actual upload, generated ahead of time rather than at page load. The video shows the step a pre-populated board can't: an image going in and vocabulary coming back out.
Both mandates live or die in one prompt, so it's the part worth reading. It gives the model a role, a vocabulary space, and a shape for its answer โ rather than a tone.
The five named categories do most of the work; that's the axis-coverage trade-off above, made concrete. "Do not guess at brand, purpose, or content you cannot see" is there because the failure mode of a vision model on a screenshot is confident invention โ it will happily tell you it's looking at a fintech landing page. That's not searchable design vocabulary, and it's frequently wrong.
The request pins responseMimeType and a responseSchema, so
Gemini is constrained to answer in a shape that can be parsed rather than scraped:
{
type: "OBJECT",
properties: {
terms: { type: "ARRAY", items: { type: "STRING" },
minItems: 5, maxItems: 10 }
},
required: ["terms"]
}
The response still gets validated with Zod afterwards. A schema makes malformed output unlikely, not impossible โ a model can still return fenced JSON, a short array, or nothing at all, and the network can fail outright.
Requests time out at 25 seconds and retry once on 429,
500, 502, 503, and 504 โ
the statuses that mean busy, try again, as opposed to this request is
wrong. Retrying a 400 just wastes time and quota. Everything that still fails
lands on the labelled floor described in the trade-offs.
Vite is never handed the .env file, and nothing in src/
talks to Google directly. The browser uploads bytes to this app's own Express
route, and that route is what calls Gemini. There's no path by which the key ends
up in a client bundle โ not by convention, but structurally.
Leave DATABASE_URL empty and the server runs
PGlite โ real Postgres compiled to WebAssembly,
in-process, writing to a local directory. Same SQL, same Drizzle migrations, no
install step. Set the variable and it switches to a real server with the same
migration files. Cloning the repo and running it takes one command, which is
the difference between a project people try and one they don't.
Specify what the model must cover, not how it should sound. Every improvement in output came from something countable โ five named axes, a term count, a word limit, Title Case, an explicit ban on guessing. "Describe the design well" is unfalsifiable, and a model can't check itself against it.
And build the feedback control at the same time as the feature it controls. I can tell you exactly what would make this project fail โ vocabulary people don't trust โ and I shipped without the one tap that would have measured it.