DEV Community

Cover image for Two Sisters Tinctures: Building an Apothecary Skincare Tracker with Gemma 4 for My Sister
Earl Grey
Earl Grey Subscriber

Posted on AI-assisted

Two Sisters Tinctures: Building an Apothecary Skincare Tracker with Gemma 4 for My Sister

Hacktoberfest Weekend Challenge: Build for a Friend Submission 🤝

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=1 to the address and you get a pre-filled demo shelf. It lives in memory only, so it is gone when you close the tab. Three phone screens: the shelf with product cards in a green cabinet, the night routine with a reason under each step, and the Add screen with the photo card greyed out as coming soon The shelf, the night routine, and the Add screen (demo shelf, invented products).

Two phone screens: the Paste screen with the How to get the best read tip open, and a small shelf showing the hint No sunscreen in your morning routine yet
The best-read tip on the Paste screen, and a gap hint on a small shelf.

Code

GitHub logo 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, built for my little sister. Built with Gemma 4 for the DEV Hacktoberfest Weekend Challenge.

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.

The shelf: product cards in a green cabinet
The shelf
Night routine with each step and its reason
Night routine, with a reason for each step
Add screen with the photo card greyed out as coming soon
Add a product (photo is coming soon)
Paste screen with the How to get the best read tip open
Paste screen with the best-read tip
Shelf with a hint that there is no sunscreen yet
A gap hint on a small shelf

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.js is 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

Two Sisters Tinctures - Block 3 Gold Set and Status Check
You

<USER_REQUEST>
Context update, read before doing anything. While your quota was out, Claude built and I pushed Block 3 steps 9, 10 and 11, so do NOT redo them. Run git pull first and read these files before changing anything: server/limits.js, server/validate.js, server/gemma.js, server/index.js, public/read-client.js, test/server-logic.test.js.

What exists now:
- POST /api/read works with the real model (tested paste, typed, and a prompt injection test, all clean). Model timeout is 30 s (MODEL_TIMEOUT_MS). Thought parts are filtered.
- DEMO CODE WAS DROPPED. No X-Demo-Code, no DEMO_CODE env var, no code box. Protection is daily caps: DAILY_CAP default 50 total, IP_DAILY_CAP default 10 per visitor, plus a per-minute limit. About screen says so (VOICE id about.limit).
- Paste and Typed on the frontend call the server through public/read-client.js and fill the existing confirm form. Snap the label is a STUB until Block 5.
- node --test test/server-logic.test.js passes 12 of 12.
- Claude tested the browser flow already. Do NOT run browser testing.

Task: Block 3 gold set. Do ONE step, then STOP and give a SHORT report (files changed, commit hash if you commit, nothing else).

STEP 12: gold set of 5 real products.
1. Create test/fixtures/gold/ with 5 JSON files, one per product: { "id", "mode": "paste", "text": "<pasted label or website ingredient text>", "expect": { "type": "<one of the 11 rules.json types>", "mustInclude": ["<2 to 3 ingredients that must be returned>"], "mustNotInclude": ["<ingredient not in the text>"] }, "source": "<URL>", "checked": "2026-10-03" }.
2. Pick 5 well-known products across different types (for example a cleanser, a retinol serum, a vitamin C serum, a moisturizer, a sunscreen). Ingredient text MUST be copied from the brand's own product page or a retailer page. NEVER invent or reconstruct an ingredient list from memory. If you cannot verify one, leave that file out, say so, and STOP so I can supply it.
3. Create test/gold.mjs: loads .env with process.loadEnvFile (wrappe
<truncated 246 bytes>
hes, mustInclude present, mustNotInclude absent) plus the time in ms, then a total like "4 of 5". Never print the API key. Do not log the full model response.
4. Add "gold": "node test/gold.mjs" to package.json scripts. It must NOT run as part of node --test (it calls the real API).
5. Do NOT run test/gold.mjs yourself. I will run it locally.
6. Add one row to TASKS.md under Block 3 for the gold set, unchecked until I run it.

Rules: one file one responsibility, no refactoring of other code, textContent not innerHTML, no em dashes in any text, add any new user-facing line to VOICE.md as an unchecked row (there should be none here). Propose nothing else. When done, run node --check on new files and the 12 existing tests, then STOP.
</USER_REQUEST>

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Files changed

Commit hash: None (not committed; awaiting your review/run).

You

<USER_REQUEST>
Read-only status check. Do NOT edit, create, or delete any file. Do NOT run the app, a browser, or any test that calls the Gemini API. Do NOT print or ask for any API key. Mock data only, this session may be published.

  1. Run git pull, then git log --oneline -15.
  2. Read TASKS.md, FINDINGS.md, AGENTS.md and PRD.md Gate G4. Verify claims against the actual files, not memory.
  3. Report back in under 25 lines:
    • Each Block (0, 1, 1b, 2, 3, 3b, 4, 5, 6): done, partly done, or not started, with the TASKS.md line that proves it.
    • Anything in TASKS.md or the PRD that disagrees with the code. Quote file and line.
    • Anything in the code that is not covered by a TASKS.md item.

Context so you read it correctly:
- Claude (not you) built Block 3 steps 9 to 12, the 53 new ingredient notes and aliases, the paste name box, the empty-shelf Add button, Block 4 prep and live checks, and Block 3b (tests, ESLint, CI). You built Blocks 0 to 2 and the gold set fixtures draft.
- The demo code was dropped on Oct 3. Protection is daily caps (DAILY_CAP 50 total, IP_DAILY_CAP 10 per visitor) plus a per-minute limit. Known limit: the per-visitor cap can be dodged with a fake X-Forwarded-For header. See FINDINGS.md.
- Gold set: 5 of 5 with a product name, 3 of 5 ingredients only.
- Block 4: key set in Render, $5 budget alert set, key limited to the Gemini API, live checks passed, Shara's cold phone check passed Oct 3.
- Block 5 (photo) is still a STUB and likely to be cut. Block 6 (video, README, DEV post, submit) is next.
- Deadline Sun Oct 4, 2026, 11:59 PM PDT. Ship target 6 PM PDT.

STOP after the report. Wait for my next instruction.
</USER_REQUEST>

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Block Status

  • Block 0 (Spike): done (TASKS.md:26: **Status:** [x]).
  • Block 1 (UI mock data): done (TASKS.md:37: **Status:** [x]).
  • Block 1b (First deploy): done (TASKS.md:49: **Status:** [x] (rollback untested; auto-deploy still on, commit trigger)).
  • Block 2 (Rules & shelf storage): partly done (TASKS.md:63: **Status:** [~]).
  • Block 3 (Server & Gemma): done (TASKS.md:77: **Status:** [x] done Oct 3).
  • Block 3b (Tests, lint, CI): partly done (TASKS.md:91: **Status:** [~] built Oct 3, waiting on first green CI run and the throwaway-branch proof).
  • Block 4 (Harden live deploy): partly done (TASKS.md:104: **Status:** [ ], item line 109 [~]).
  • Block 5 (Photo input): not started (TASKS.md:114: **Status:** [ ]).
  • Block 6 (Her test, video, post): not started (TASKS.md:122: **Status:** [ ]).

Spec vs. Code Discrepancies

Code Not Covered by TASKS.md Items

  • server/index.js:77-80: origin/referrer checking against host header to reject foreign origins on /api/read.
  • server/gemma.js:46-52: unwrap logic for array-wrapped model outputs or string double-encoded JSON payloads.
You

<USER_REQUEST>
Use the devrelay-sessions skill. Do NOT edit any project file.

  1. Save or publish this Antigravity session through DevRelay so it can be linked from a DEV post. Make it public-viewable if DevRelay offers that choice.
  2. Give me back: the exact embed tag or link for the DEV post, the visibility setting (public or private), and whether the session includes messages from before DevRelay was installed.
  3. Before publishing, scan this session for anything that must not be public: API keys, tokens, .env contents, real email addresses or phone numbers, home folder paths. List what you find with the message it is in. If anything is found, do NOT publish. Stop and tell me.
  4. Do not create or publish a DEV article. Claude is writing the post.

Report in under 15 lines, then STOP.
</USER_REQUEST>

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

Pre-Publication Scan Results

DO NOT PUBLISH: Private home folder paths and .env references were detected in the session history.

  1. Home folder paths (~\...):
    • Step 0 (User message 1): <ADDITIONAL_METADATA> contains active editor path ~/two-sisters-tinctures\.env and open file paths under ~\projects\....
    • Step 102 (User message 2): <ADDITIONAL_METADATA> contains active editor path ~/two-sisters-tinctures\.env.
    • Step 121 (User message 3): <ADDITIONAL_METADATA> contains active editor path ~/two-sisters-tinctures\.env.
    • Tool calls (Steps 7, 9, 11, 13, 15, 17, 21, 23, 25, 59, 77-93, 108-118, 123-127): Absolute file paths containing ~\....
  2. .env references:
    • Active file metadata references the .env filename in user messages 1, 2, and 3 (though actual secret keys were not printed or logged in the transcript).
  3. API Keys / Emails / Phones: None detected.

DevRelay Details

  • Embed tag: {% agent_session <id_or_slug> %} (will be assigned upon sanitization and submission).
  • Visibility: DEV agent sessions submitted via submit_agent_session are created as unpublished/draft by default (author-viewable only) until published.
  • Pre-DevRelay messages: This conversation ID (a535ecb3...) began at Step 0 with the Block 3 gold set prompt (after DevRelay was installed); it does not include earlier sessions from Blocks 0–2.
You

<USER_REQUEST>
Use the devrelay-sessions skill. Do NOT edit any project file. Do NOT publish anything.

  1. Submit this session with submit_agent_session. Keep it unpublished (author-viewable only). Do not create a DEV article
  2. Give me back: the session id or slug, the exact embed tag for the post, and the DEV URL where I can open the draft and read it.
  3. Tell me what DevRelay did to the home folder paths (redacted, kept, or removed), based on what you can see in the result.

Report in under 10 lines, then STOP.
</USER_REQUEST>

Agent
Agent
Agent
Agent
Agent
Agent
Agent
Agent

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)