Easy-to-manage touch screens
Free, open-source touch screens. Guided USB setup, a drag-and-drop editor and local control. Made for Home Assistant.
Interactive meal diary gallery
Translation pending
LifestyleJavaScript · CSSGitHub @lucypham4OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
A meal-logging app for any chef, a React + Vite app implementing the Staj
design system, backed by Supabase (data + auth) and Cloudinary
(photos).
An account is entirely optional. Open the app and you're straight into
your own diary, meals persist to that browser via localStorage, no
sign-up required. Creating an account is an opt-in upgrade for two things:
a live, public, read-only page at / you can hand to
clients, and access to your diary from more than one device (local-only
data stays on that one browser).Read the full README ↗A meal-logging app for any chef, a React + Vite app implementing the Staj design system, backed by Supabase (data + auth) and Cloudinary (photos).
Staj is for any chef, in any kitchen. Moving into private chef work is one use case, not the audience. Nothing in the app assumes it, and a chef doesn't have to say what they're using it for.
An account is entirely optional. Open the app and you're straight into
your own diary, meals persist to that browser via localStorage, no
sign-up required. Creating an account is an opt-in upgrade for two things:
a live, public, read-only page at /<your-page-name> you can hand to
clients, and access to your diary from more than one device. Meals logged
before you sign up can be copied into the new account (see
Local Import).
The app has two tabs, Home and Add. Everything else opens from those, or from the round profile picture at the top right of Home, which goes to your profile.
/<your-page-name>: a read-only version of Home for
clients. At the top, centred: your picture and name, your links, your
bio and your specialties. As the page scrolls, your picture and name
shrink and slide into a bar at the top left, and stay there with the
dishes fading out beneath. The same
dish sheet and the same swiping, in the light or dark theme the chef
picked. A link like /<your-page-name>?meal=<id>
opens one dish.There is no router library. / is the private app, a single path segment
is a chef's public page, and ?meal=<id> opens one dish.
No export. The app used to download a copy of the diary as an HTML file. That was removed in favour of the public page and Local Import; see ADR 0003.
For what the words Chef, Local meal and Local Import mean here, see
CONTEXT.md. Decisions that shaped the app are in
docs/adr/.
npm install
npm run dev
npm test
Playwright drives the built app (not the dev server, so it exercises what actually ships) against fake Supabase credentials, intercepting every Supabase request. No test reaches a real project.
The browser is expected to be already installed. If Playwright reports a
missing browser, run npx playwright install chromium once.
The suite has a spec per area that is easy to break without noticing:
add-meal-ai-failure, serves, dish-summarydish-sheet-motion,
dish-step-motion, dish-swipemeal-delete, password-reset, settings-profilebottom-nav, cornersfirst-launchThey live in tests/.
Works immediately with no environment variables, meals persist to that
browser's localStorage and photos fall back to local blob URLs (see
below). Configuring Supabase and Cloudinary makes signing in for a public
page and cross-device access possible on top of that.
.env.example to .env and fill in the four values (see below).supabase/schema.sql once
(creates the meals table), then supabase/multi-chef-migration.sql
once (adds the chefs table, scopes every meal to its own chef, and
sets the row-level security policies this app actually relies on today.
See Security notes below), then supabase/photos-array-migration.sql
once (adds the photos column meals now use for up to 6 photos each),
then supabase/page-theme-migration.sql once (adds the page_theme
column that stores each chef's light/dark choice for their public
page), then supabase/avatar-migration.sql once (adds the
avatar_url column for the profile picture set on the profile), then
supabase/summary-migration.sql once (adds each meal's one-sentence
summary, and the policy that lets a chef update their own meals so an
older meal's summary can be saved; see below), then
supabase/meal-delete-migration.sql once (lets a chef delete their own
meals; without it a delete looks as if it worked and changes nothing),
then supabase/bio-migration.sql once (adds the bio column for the
short bio a chef writes on their profile; until it has run, saving a
bio fails and says so), then supabase/profile-details-migration.sql
once (adds the specialties and links columns, likewise), then
supabase/dish-order-migration.sql once (adds the dish_order column
that holds the order a chef drags their dishes into, which their public
page shows too; until it has run, saving an order fails and says so).npm run dev, the notice banner disappears once both are
configured, and the Add tab persists real rows with real photo
URLs.| Variable | Where to find it |
|---|---|
VITE_SUPABASE_URL | Supabase → Project Settings → API → Project URL |
VITE_SUPABASE_ANON_KEY | Supabase → Project Settings → API → anon public key |
VITE_CLOUDINARY_CLOUD_NAME | Cloudinary → Dashboard |
VITE_CLOUDINARY_UPLOAD_PRESET | Cloudinary → Settings → Upload → your unsigned preset's name |
Without Cloudinary, photos still work locally (a blob URL) but won't survive a page reload, even though the meal itself does. Without Supabase, the app just stays in local-only mode permanently, signing in for a public page or multi-device access needs it configured.
Note: every chef on a given deployment shares the same Cloudinary account and upload quota, there's no per-chef Cloudinary isolation yet.
Filling in a meal by hand is the biggest source of friction in "Add a meal", so the form leads with the two low-effort inputs and lets AI do the rest. Adding a meal is three steps:
Clean up, beside Description on step 3, tidies the description: fixes grammar and punctuation, drops filler words ("um", "like"), keeps your own words and every detail you gave.
The fill is an enrichment, never a gate. If it fails (the function isn't
deployed, the key is missing, the network drops) or takes longer than 30
seconds (AI_FILL_TIMEOUT_MS in src/lib/aiFill.js), the shimmers stop and a
notice on step 3 says why. Your notes are carried across as the description
and every other field is left empty, so you can finish the card by hand and
save it normally. Without Supabase configured at all there is no fill: step 3
opens with your notes as the description.
The fill and Clean up only run once Supabase is configured (above), since they need a place to run a server-side call that keeps the API key off the client. They call Google's Gemini API rather than a paid provider so they run on the free tier of Google AI Studio with no billing required:
supabase functions deploy ai-fill,
supabase functions deploy clean-description and
supabase functions deploy summarize-dish (from
supabase/functions/; all three import from supabase/functions/_shared/,
which the CLI bundles in).supabase secrets set GEMINI_API_KEY=... on the same project (a free
key from Google AI Studio, shared
by both functions; an optional GEMINI_MODEL secret overrides the
default model, currently gemini-3.6-flash). An optional
GEMINI_FALLBACK_MODEL secret names a second model for all three
functions to try when the first answers 503 or 429 (see "If AI fill
fails" below). The functions share supabase/functions/_shared/gemini.ts
for that, and supabase/functions/_shared/summary.ts for the summary.The line on a dish's card is a one-sentence summary written to fit
two lines, never the description cut off. The AI fill writes one for each
new meal, shown in step 3 as Summary for the chef to change or clear.
A meal logged before that gets one from summarize-dish the first time
its chef opens it, saved once. Until then — or without the function, the
migration, or AI at all — the card uses the description's first sentence
if that fits, or a line made from the ingredients ("Leek with brown
butter and hazelnut."). Public pages never ask for one: their readers
can't save it.
The free tier has per-minute/per-day rate limits, comfortably enough for one app's personal use, but worth knowing about if it starts erroring under heavier use.
Without the ai-fill function deployed, the fill fails like any other
failure: the notice on step 3 says what's missing rather than the card
silently staying empty.
If AI fill fails, the wizard carries on. A failing Edge Function used
to strand a chef on step 2 with "Edge Function returned a non-2xx status
code", which is what supabase-js reports for any non-2xx and says
nothing about the cause. The wizard now opens step 3 straight away and,
when the fill fails, stops the shimmer, carries the notes over as the
description, and says "The AI couldn't fill this in", with a Try again
button that re-runs the fill for the same photo and notes and fills only
what the chef hasn't edited since. A 503 or 429 from the model is tried up
to three times, with a pause that doubles (1.5 seconds, then 3) before the
notice shows at all; nothing else is retried (the card keeps shimmering
through the retries, and the whole fill still ends at 30 seconds). If
GEMINI_FALLBACK_MODEL is set, each of those tries also asks that model,
straight away, when the first one is busy. Clean up and the summary
written for an older dish do the same (clean-description and
summarize-dish): up to three tries, and the fallback model. When Clean up
still fails, a plain line says so under the description ("The AI couldn't
clean this up. Still busy. Try again in a minute." for a 503 or 429, "Your
description is unchanged." otherwise) with the model's own words behind its
own Details toggle, closed by default, as here; the dish summary just
leaves the card as it was. If Try again
fails too, a line under the button says why it's worth another go or not:
"Still busy. Try again in a minute." for a 503 or 429, "That didn't work.
You can fill it in below." for anything else. It goes when the chef presses
Try again again or edits the card.
The Edge Function's own message is the one worth reading, so it isn't
thrown away: it's logged with console.error and sits under the notice's
Details toggle, closed by default:
| What it says | What to do |
|---|---|
GEMINI_API_KEY is not configured on this project. | supabase secrets set GEMINI_API_KEY=... |
AI request failed (404): ... | The model in GEMINI_MODEL doesn't exist for your key; set it to one that does |
AI request failed (429): ... | Free-tier rate limit; it was already tried three times, so wait a bit longer and press Try again |
AI request failed (503): ... | The model is overloaded ("high demand"); it was already tried three times (and the fallback model, if set), so press Try again in a minute |
Couldn't reach the AI just now. | No response body from our handler, so the function isn't deployed or the request never reached it |
The AI took too long to answer. | Nothing came back within 30 seconds; try again, or check the function's logs |
To see the automatic retries, the Try again button and the notice without
waiting for the model to really be busy, scripts/ai-stub.mjs stands in
for Supabase and answers ai-fill the way the real Edge Function does when
Gemini is overloaded: HTTP 502 with AI request failed (503): ... in the
body. Two terminals:
# 1: the stub
FAIL=3 node scripts/ai-stub.mjs
# 2: the app, pointed at it
VITE_SUPABASE_URL=http://localhost:54321 VITE_SUPABASE_ANON_KEY=stub npm run dev
Open the app, tap Add, add a photo, write some notes and press Next.
FAIL is how many ai-fill requests fail before the stub starts
succeeding:
FAIL | What you should see |
|---|---|
3 (default) | You land on step 3 as soon as you press Next, its fields shimmering. The stub logs three 503s, the second 1.5 seconds after the first and the third 3 seconds after that (the shimmer carries on through the retries; a screen reader is told "Trying again…"), then the shimmer stops and the card says "The AI couldn't fill this in". Try again says "Trying again…", then fills the card and dismisses the notice. |
1 or 2 | The automatic retries rescue it, so there is no notice. |
99 | Every request fails. Try again returns to "Try again" with "Still busy. Try again in a minute." under it, and Details shows the raw 503 text. |
To see the other line, "That didn't work. You can fill it in below.", reach
step 3 with FAIL=99, then stop the stub and press Try again: nothing
answers, which isn't the model being busy.
To check that hand edits survive, change the meal name on step 3 before
pressing Try again: it stays, and the empty fields fill in. The stub's log
timestamps each request, and FAIL counts requests since it started, so
restart it between runs. If supabase start already has port 54321, set
PORT on the stub and in VITE_SUPABASE_URL. The Playwright suite doesn't
use the stub: it intercepts the network itself, per test.
Settings → Account has a Sign in button whenever Supabase is configured, nothing forces you through it. Sign-up is open from there ("New chef? Create an account"), no admin approval step.
/<slug> in your public URL and can't be
changed later, so choose deliberately./<slug> page,
that's the link to actually share.Local Import. Right after a new account is created, if that browser
has meals in local storage, the app offers to copy them into the account.
It asks first, runs once, and clears each meal from local storage only
after it has been copied; any that fail stay where they are, and
Settings → This device offers a retry. It never merges into an account
that already has meals, so a second browser's local meals aren't pulled
in. The reasoning is in
ADR 0001 and the code in
src/lib/localImport.js.
If your Supabase project has "Confirm email" turned on (Authentication → Settings), new sign-ups won't get a session until they click the link in their inbox. The sign-in screen tells them to check their email and switches back to the sign-in form.
Forgot password? under the password field emails a reset link (Supabase's own "Reset Password" email). The link signs the chef in and lands on a screen to choose a new password before anything else. For the link to come back to the right place, the address it's requested from has to be allowed in Supabase → Authentication → URL Configuration:
http://localhost:5173/** for local work, or the Vercel preview
domain with a ** wildcard.A link that has expired (after an hour) or was already used, sometimes by a mail scanner opening it first, lands on the sign-in screen saying so, with both ways to ask for another email right below.
There's no welcome tour. A first launch lands straight on Home, whose empty state says what the app is for and offers Add your first dish, which opens Add.
The one thing worth asking a guest is whether they want an account, and only at the moment it matters: when they try to share a page they don't have yet. Share on their profile then opens a sheet, "Sign up with your email to make your page live.", that leads into sign-up or sign-in and can be dismissed with Not now. The same offer sits on a guest's profile where a chef's bio would be. Nothing else in the app asks.
The app doesn't ask what a chef plans to use Staj for. That is deferred until research shows the answer would change what they see. See ADR 0004.
src/styles/tokens.css holds the colour, type, space and motion tokens,
in a light theme, a dark theme, and a reduced-motion variant. Two notes
worth reading before adding UI:
docs/design-system/motion.md — the
motion tokens, the shared keyframes, and why a component written
against the tokens is reduced-motion correct without its own media
query; the sliding toggles and the sheen on fields the AI is filling in.docs/design-system/shape.md — the
no-straight-corners rule, the four radius tokens that carry it, and why
photos get a hairline outline rather than a shadow; what the three
button colours mean (red is one button) and how buttons are set in type.Theme follows the operating system by default. The Appearance control in Settings cycles System → Light → Dark and remembers the choice.
The ios/ folder is a Capacitor-wrapped native shell around this same web
app, npx cap add ios already scaffolded it, so the JS-side setup is
done. Everything past this point needs a Mac, which this environment
doesn't have:
git pull this repo.npm install && npm run cap:sync (builds the web app and copies it
into the native shell).npm run cap:open:ios to open the project in Xcode.capacitor.config.json is com.staj.app, change
it to match your account before submitting), and run on a simulator or
device to test.GEMINI_API_KEY is a Supabase Edge Function secret, not a
VITE_-prefixed client variable, it must never end up in the browser
bundle. The ai-fill, clean-description and summarize-dish
functions are the only things that read it.supabase/multi-chef-migration.sql, not phase2-auth-policies.sql
(superseded, kept only for history). The migration makes meal reads
public again (needed for public chef pages) and scopes every write to
auth.uid() = user_id, so one chef's account can never read or write
another chef's rows even though the table is shared.vercel.json rewrites every path to /index.html, required for a
direct hit on /<slug> to work, since routing happens client-side.