This is a submission for the Hacktoberfest Weekend Challenge: Build for a Friend
TL;DR: I built a skincare shelf tracker for my little sister. Gemma 4 reads a product (typed name or pasted ingredients) and fills in the card. The model gets to read the label. It does not get a vote. A plain rules file decides what goes on first, what waits until night, and what should never be layered with what.
What I Built
My little sister is my skincare product whisperer. Every time I need a cleanser, a serum, or a sunscreen, I ask her. She knows what she uses, what works, and what she gave up on, and all of that lives in her head and on her bathroom shelf.
So for Build for a Friend, I built her a place to keep it: a private skincare shelf. It helps her keep her own routine straight, and the next time I ask "what should I get?", her answer is already on her phone. She can screenshot her shelf and send it to me.
Two Sisters Tinctures lets her add a product by typing its name or pasting its ingredient list, keep track of what she owns, see when something has been open too long, and get a morning and night routing, with a warning when two things on her shelf should not be layered. Her shelf lives in her own browser. There is no account.
I didn't want AI deciding my sister's skincare routine. I wanted it doing the annoying reading.
So the design rule became: the model reads, the rules decide. Gemma 4 reads the label and drafts the card. She checks it and taps save. A plain rules file, not the model, decides the order, what waits until night, and the warnings. Nothing lands on her shelf without her confirming it.
If I had to say it in one breath:
I built a private skincare shelf for my sister where AI does the fuzzy reading, but deterministic code makes the decisions. The model cannot silently add something to her shelf, rules don't depend on model improvisation, her data stays in her browser, and I measured where the model fails instead of hiding it.
Then I handed it to her. What she said is at the end of this post.
Demo
- Live app: https://two-sisters-tinctures.onrender.com/
- Want to look around without adding anything? Add
?demo=1to the address and you get a pre-filled demo shelf. It lives in memory only, so it is gone when you close the tab.
The shelf, the night routine, and the Add screen (demo shelf, invented products).

The best-read tip on the Paste screen, and a gap hint on a small shelf.
Code
earlgreyhot1701D
/
two-sisters-tinctures
A skincare shelf tracker I built for my little sister. Paste the ingredients, type a name, or snap the label, and Gemma 4 explains what it does. Plain rules handle routine order, don't-mix warnings, and shelf life, and her shelf never leaves her phone. Built for the DEV Hacktoberfest Weekend Challenge: Build for a Friend.
Two Sisters Tinctures
A skincare shelf tracker built for my little sister. She adds a product, it becomes a card on her shelf, and the app sorts the shelf into a morning and evening order and flags things that should not be layered.
Live: https://two-sisters-tinctures.onrender.com/
Look around first: add ?demo=1 to the address for a pre-filled demo shelf (memory only, gone when you close the tab).
Built for the DEV Hacktoberfest Weekend Challenge: Build for a Friend (October 2 to 5, 2026).
AI assisted. Human approved. Powered by NLP.
Screenshots
Phone-sized views of the demo shelf (?demo=1). The products in the demo are invented.
What it does
- Add a product by typing its name, or pasting the ingredient…
It is one small Node service. It serves the static front end, one health check, and one API route, POST /api/read. The shelf itself lives in the browser's localStorage. There is no database and no account.
How I Built It
The open model at the center is Gemma 4 26B A4B (gemma-4-26b-a4b-it), open weights under Apache 2.0, served through Google's Gemini API. Every product that lands on her shelf starts as a Gemma read, and everything after that read is plain code.
I do not write code. I direct, agents generate, I validate and decide. This one was a three-way build, so here is who did what:
| Who | What |
|---|---|
| Me | Wrote the PRD first, set every MUST / STUB / NEVER label, approved each block, tested on a Samsung and two iPhones, made every call about what the app says to my sister |
| Antigravity (mostly Gemini 3.8 Flash on low, Sonnet 4.6 when I hit my limits, with the DevRelay MCP connected) | Blocks 0 to 2 (project skeleton, the shelf and its screens on mock data, rules and shelf storage), the first gold fixture draft and the gold set runs in Block 3 (the session embedded below), and after my credits reset: the Block 7 polish (greyed photo card, best-read tip, gap hints), the ingredient-rule bug fixes, and the route tests and static file hardening |
| Claude (in Cowork) | Block 3 steps 9 to 12 (rate limits, output validation, the Gemma call, the /api/read route, front end wiring), the 53 ingredient notes, Block 4 live checks, Block 3b tests, lint, and CI, reviews of Antigravity's later plans and changes against the files, the bug hunt diagnosis, the README, the screenshots, and the first draft of this post |
| Gemini | The hero image |
| Stitch | The UI design |
The order I used: PRD first, then blocks, with a pass or fail check after each one. Mock data first, so the layout worked before any API was wired to it. Features not in the current phase got a comment stub with notes, not a half-built version.
The model reads. The rules decide.
This is the one design call I care about most. Gemma is good at turning a messy label into structured fields. It is not who I want deciding whether a retinol serum and a glycolic acid toner should share a night.
So the split is deliberate. Gemma only ever answers one question: "what is this product?" Everything after that comes from public/rules.json, a plain file I can read: the product types, the morning and evening order, the conflicts, and 86 ingredient notes with aliases. Structure is deterministic. Flavor, meaning the one-line description, is the model's.
Here is the whole path a product takes:
what she types or pastes
↓
Gemma 4, called from a small Node service on Render
↓
validator: right shape, a known type, only ingredients she pasted
↓
she checks the card and taps save
↓
rules.json: order, night-only, warnings
↓
her shelf, stored in her own browser
Then the model's answer goes through a validator before the app trusts it. The validator:
- rejects anything that isn't the expected shape, and reports a reason code, not the raw text
- trims names and descriptions that run too long
- throws away any ingredient that does not appear in what she pasted (ignoring case, spacing, and dash style)
- replaces a "what it does" line that mentions an ingredient the product doesn't list If the model is down, slow, or returns garbage, the app says so and offers to add the product by hand. A blank screen was on my NEVER list.
The gold set
The first time I ran my gold set against the model, it scored 0 of 5. It took four runs and a handful of fixes to get to 5 of 5, and there is a catch in which 5 of 5 that is.
The gold set is five skincare products I bought or looked up. Each has an expected type and ingredient list I checked against the source. Two of the first fixtures an agent drafted for me were partly made up (a wrong source link, and ingredient lists I could not verify), so I checked each one against the product pages and replaced them.
| Input | Reads correct |
|---|---|
| Product name plus ingredients | 5 of 5 |
| Ingredients only, no name | 3 of 5 |
With only an ingredient list, the BYOMA serum and the CeraVe cleanser both came back as "Moisturizer." That is a fair guess from a list of lipids and humectants, and still wrong. That is the reason the Add screen has an optional product name box on the Paste tab.
Input: ingredient list only
Measured: 3 of 5 types correct
Response: optional product name box, ingredients checked against what she pasted, her confirmation before saving
Result: the model stays useful without being treated as the authority
A read takes about 2 to 4 seconds live.
Protecting a model key on a public link
The model runs on the Gemini API, paid tier, so every read has a small cost. Here is what keeps a public link affordable:
- 50 reads a day for the whole app, 10 a day per visitor, and a per-minute limit
- same-origin check on the API route
- the key is only in the server's environment, never in the browser, never in the repo
- the key is restricted to the Gemini API, and a $5 budget alert sits behind all of it
The bug hunt the day before submitting
The tests were green and CI was green. Then I used the app, and almost everything I added landed on "Night only." One ingredient also vanished between pasting and saving.
The model was not the problem. The rules file was. The rule that sends strong actives to night matched any ingredient name that contained an acid's name, at any strength, on any product. I ran it against my own gold set:
| Product | What tripped the rule | What the app did |
|---|---|---|
| BYOMA Hydrating Serum | Lactic Acid, there only to adjust pH, near the end of the list | Night only |
| L'Oreal Age Perfect SPF 30 | Capryloyl Salicylic Acid, which contains the words "salicylic acid" | A daytime sunscreen, set to Night only |
The same loose match fed the "don't mix tonight" warnings, so the same products would have raised false alarms next to a retinol. The vanishing ingredient was two smaller things: the save step split the list on every comma, which turns "1,2-Hexanediol" into "1" and "2-Hexanediol", and the paste guard dropped any name the model wrote with a different dash than the label.
The fix stayed deterministic. One small matcher now handles every ingredient rule. It matches whole ingredient names. Acids only count in the first ten ingredients, where the stronger ones sit, which is a rule of thumb and I wrote it down as one. Anything typed Sunscreen, or with SPF and a number in the name, is always morning. Conflict warnings only fire between two different products that both get used at night. Each of those cases is now a test, using the two products that broke it.
The lesson I am keeping: the deterministic part needs the same testing as the model.
Tests and checks
48 tests run with npm test: 10 added during the bug hunt and 8 route tests that start the real server and check each error path of the API. ESLint runs with a rule that flags unsafe DOM writes, since I only use textContent, never innerHTML. GitHub Actions runs lint, tests, and npm audit on every push, and it is green.
What it does not do yet
- Photo input is not built. The card on the Add screen is greyed out and says "Coming soon." I tested a web lookup for ingredients instead (it found the right list for 2 of 2 products I tried by hand), and stubbed that too, because two products is not enough proof and it needs a second key. I cut both so I could finish what I had.
- Ingredients-only reads are right 3 times out of 5 in my gold set. Add the product name.
- Editing a saved product's details is coming soon. Changing the type works today.
-
app.jsis one file doing too many jobs (about 1,600 lines). Splitting it is on the after-the-contest list in the repo, instead of rushed the night before. - Only the Gemini path is tested. Ollama is a stub.
- No allergy checks, and this is not medical advice. It is a shelf and a set of order and conflict notes.
Why Does Open Innovation Matter?
Gemma 4 is open weights, and Google's own Gemma terms page points Gemma 4 to an Apache 2.0 license. This weekend I call it through Google's API, the same way I would call a closed model. What open changes is how much of the system I get to control.
The model is a part, not the brain. Gemma's answer has to pass a narrow contract: a fixed shape, a type from my list of 11, and only ingredients that appear in what she pasted. The skincare logic never sees anything else. So the reader can be swapped without rewriting a single rule, and with open weights the swap can be the same model on hardware I control. The model call lives in one file behind one setting, MODEL_PROVIDER. The local path (Ollama) is a stub with notes, not tested.
I get to keep it. Apache 2.0 means a vendor changing its terms does not take the reader away from her shelf. A closed API gives me no exit like that.
Failures stay visible. I measured where the model is wrong, 3 of 5 from ingredients alone, and designed around it: a name box, the validator, and her confirmation. A model mistake cannot quietly become something her shelf believes.
Open also means being clear about where her data goes:
- Her shelf stays on her phone. It lives in the browser's own storage. It never goes to my server, and there is no account.
- What does leave the phone is the product name or ingredient text she types or pastes, sent to Google's paid Gemini API so Gemma can read it. Google's Gemini API terms say that on paid services it does not use prompts or responses to improve its products. I checked that page in early October 2026, and I would re-read it before relying on it.
- My server keeps none of it. It logs reason codes and timings only, never what she sent. No photos are sent, because photo input is not built.
My Agent Session
This session covers one slice of the build: the Block 3 gold set runs and a status check, in Antigravity with the DevRelay MCP. Antigravity sessions used mock data and public product ingredient lists only, nothing personal. The later Antigravity work (Block 7 and the bug fixes) and the Claude sessions are not in this embed.
Prize Categories
Best Use of Gemma. Gemma 4 (gemma-4-26b-a4b-it) reads typed names and pasted ingredient lists into structured product cards. It runs with thinking set to minimal so reads come back in seconds, and a validator checks every answer before the app uses it.
Best Use of Render. Render runs the one part that has to be a server: a small Node service that holds the Gemini key and talks to Gemma. The path is her browser, then the Render-hosted service, then a server-side Gemma request, then the validator, then back to her browser, where the shelf is stored. The key lives only in Render's environment settings, so her browser never receives it. The same service enforces the daily caps, the per-minute limit, a same-origin check, and the security headers. It is defined in a render.yaml Blueprint and deployed on each commit during the build. The smallest paid web service on Render's pricing page was $7 a month when I checked in early October 2026.
The handoff
When I told my sister I was building her a skincare shelf, she texted back:
Omg yesssss! 🧪
Then I handed it to her. After she tried it, she wrote:
Loving this! A lot of people have questions on which order to use the products too 🤓
Order is exactly the job I gave the rules file, not the model. And by her count, a lot of people are asking the same question.
I built the whole thing for one reader, and she gets the final say on whether it is any good. Her shelf is also where I will look the next time I need a sunscreen.
If you have a product whisperer of your own, what would you build for them?
Research note: I checked the model, hosting, and challenge details against their own pages in early October 2026. These tools change quickly, so check the current docs before you copy anything.
AI Assisted. Human Approved. Powered by NLP.
Context: I'm L. Cordero. I work in the California courts running court operations for the county, and I build with AI on the side. I don't write code. I direct, agents generate, I validate and decide. More of what I've built is in the Clew Suite.






Top comments (0)