โ† Rayna Liu

AI Moodboard

References that arrive already named.

Role
Solo โ€” problem, product, design, build
Stack
React, Express, Gemini, Drizzle, Postgres / PGlite
Live
moodboard demo
Source
github.com/rainbow-rayna/ai-moodboard

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.

Screenshot of a populated week goes here โ€” framed so the tags are legible, because the tags are the entire argument. A board of pretty Polaroids with unreadable captions shows the wrong thing.

Worth staging a week for this rather than using the current demo board: five or six images across different days, ideally a mix of interfaces and photographs so the vocabulary visibly spans style, layout, type, colour and material. Save as public/moodboard/board.jpg and swap this box for <img src="/moodboard/board.jpg" alt="โ€ฆ"> plus a <figcaption>.

Who it's for

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.

Who
Someone with hundreds of saved images and no idea what's in them. They have excellent taste and an unusable archive, and the second thing is slowly cancelling the first.
When
Two separate moments, and the gap between them is the whole problem. The save: mid-scroll, something looks good, screenshot, one second, no thought. The retrieval: weeks later, needing "that thing with the huge type and the beige", scrolling a grid of four hundred thumbnails.
Goal
Not "organised screenshots". They want to reuse the quality they spotted โ€” find more like it, brief a collaborator with words that mean something, or finally put language to their own taste.
Today
A camera roll, a Figma page, a Pinterest board, an Are.na channel. All of them store the image; none of them say what makes it work. Pinterest will name things for you, but in commerce vocabulary, and only inside Pinterest.
Paste 2โ€“3 real lines here from the people you showed it to โ€” a sentence each, first name or "a designer friend" as the attribution. The most useful one is whoever disagreed with a tag. Use this shape:

<blockquote>"โ€ฆ"<cite>โ€” Friend, on first use</cite></blockquote>

Root cause

"My references are disorganised" points at folders, and folders are not what's broken.

  1. Symptom A reference folder stops being useful somewhere past about fifty items. It keeps growing; it just stops getting opened.
  2. Why? Nothing in it can be found. Searching images requires words, and no words were ever written down.
  3. Why weren't they? Tagging at save time costs the one second the save had. The save is impulsive by nature โ€” add friction there and you don't get tidy tags, you get fewer saves.
  4. Why not tag later, then? Because naming why a design works is a different skill from noticing that it works. You can feel a layout is good long before you can say "asymmetric balance, broken grid, tight tracking".
  5. Root cause The moment of noticing and the ability to name are separated โ€” and the naming is a skill the collector may not have yet. Storage was never the problem. Vocabulary is.

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 MVP

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.

Trade-offs

How I'd know it worked

Walkthrough

Record 45โ€“75 seconds: dragging a screenshot in, the analysis running, the Polaroid landing with real tags, then paging back to a previous week. Export as MP4 (H.264), save as 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.

Execution: the prompt

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.

ANALYSIS_PROMPT
You are a design critic building a visual reference library. Look at this image and name the design characteristics you can actually see. Return between 5 and 10 terms. Rules for each term: - Established design terminology only โ€” the vocabulary a designer would search by. Draw across style movements (e.g. Brutalist, Swiss Style, Memphis, Bauhaus, Y2K, Skeuomorphic), layout (e.g. Modular Grid, Asymmetric Balance, Split Screen, Broken Grid), typography (e.g. Grotesque Sans, Variable Weight, Oversized Type, Tight Tracking), colour (e.g. Muted Earth Tones, Duotone, High Chroma Accent, Monochrome), and material or texture (e.g. Glassmorphism, Grain Overlay, Halftone, Soft Shadow). - 1 to 3 words. Title Case. No sentences, no explanations, no hashtags, no trailing punctuation. - Concrete and visible in this image. Do not guess at brand, purpose, or content you cannot see. - No duplicates and no near-synonyms of each other. Prefer specific over generic. - Order them most to least defining.

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.

Structured output, so the happy path is a parse

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.

The failure ladder

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.

Execution: the plumbing

The API key cannot reach the browser

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.

Zero-setup Postgres

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.

What I'd change

What I'd tell myself at the start

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.