每株你照顧的植物,都得到應有的關懷與認可:它是什麼、今天需要什麼,以及完整的故事,交給下一位主人。由兩位熱愛植物、喜歡分享的人製作。
AI 翻譯
Every plant you keep, given the care and the credit it's due: what it is, what it needs today, and the whole story to hand on to whoever gets it next. Made by two plant people who'd rather share it th
生活風格HTML · TypeScriptGitHub @d13-ai上線中營運者未確認
試用過了嗎? 點一下就好,不需要登入。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證每一株您栽培的植物,都會獲得應有的照顧與認可:它是什麼、今天需要什麼,以及完整的故事,傳遞給下一位照顧者。由兩位熱愛植物的人製作,他們寧願分享也不願私藏。
AI 翻譯
Every plant you keep, given the care and the credit it's due: what it is, what it needs today, and the whole story to hand on to whoever gets it next. Made by two plant people who'd rather share it than keep it to themselves.
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
A CARFAX for plants. Photograph each plant you own, log what you do for it,
see what it needs today, and hand its whole history to whoever you trade or
sell it to. Cuttings trace back to their mother plant.
The idea, personas, rules and roadmap are in docs/PRODUCT.md.
Local-first: your plants live in SQLite on the device, and the app works
with no account at all. Sign in — with Google, or an email and a
code; no passwords — and the whole greenhouse (plants, care history,
photos) is saved to your account and syncs to any phone you sign into. See
Accounts and sync.檢視完整 README ↗A CARFAX for plants. Photograph each plant you own, log what you do for it, see what it needs today, and hand its whole history to whoever you trade or sell it to. Cuttings trace back to their mother plant.
The idea, personas, rules and roadmap are in docs/PRODUCT.md.
Local-first: your plants live in SQLite on the device, and the app works with no account at all. Sign in — with Google, or an email and a code; no passwords — and the whole greenhouse (plants, care history, photos) is saved to your account and syncs to any phone you sign into. See Accounts and sync.
Publishing a plant's tag makes that one plant's synced record public at a link anyone can open:
https://plantparlour.org/tag?t=<token>
Links are unlisted (32-hex random token), read via a SECURITY DEFINER
RPC — there is no anonymous read access to any table. Publishing needs an
account: sign in with Google or an email code first.
Not yet: public greenhouses, trades. See the roadmap.
Project plant-care (ixagjvntbgyqemxxinqe, us-east-1). .env holds the
URL and publishable key (safe to commit — RLS gates everything).
supabase/migrations/…_plant_passports.sql — tables keepers, plants,
care_events, photos; RLS (keepers touch only their own rows);
public-read bucket plant-photos with per-keeper write folders;
passport(token) RPCsupabase/migrations/…_greenhouse_sync.sql — those tables become the
synced copy of each keeper's whole greenhouse: client-generated uuids,
location and cadences on plants, deleted_at tombstones, a server-stamped
synced_at as the pull cursor; plants private (is_public = false) by
defaultapi/tag.ts — the public HTML tag page, served by Vercel at /tag?t=…
(Edge Functions rewrite text/html to text/plain, so it can't live
there; supabase/functions/tag now only redirects old links)
(deployed with JWT verification off; it only calls the RPC)Anonymous sign-ins are off (Authentication → Sign In / Providers). v1 used one to give a keeper an id before they had an account, but an anonymous sign-in against the publishable key that ships in the app is a fresh keeper for the asking — which is how a script walks past the per-keeper AI cap. Scanning, care guides and tags all ask the keeper to sign in instead.
The parlour belongs to an account. Anyone without one gets the welcome
screen and nothing else — a deep link to a plant waits for them until they
are in (RequireAccount in src/app/_layout.tsx). The greenhouse used to be
open to anyone and nagged about signing in later, which left a keeper's plants
in one browser's storage with nothing tying them to a person.
Signing in: Continue with Google (one tap; web app for now) or an email and a password — one Continue button, which signs you in or makes the account, because Supabase answers a wrong password and an unknown email identically (by design, so a form can't be used to discover who has an account) and signing up second is what tells them apart. Signing in the same way on another phone brings the greenhouse over. Signing out stops this phone showing it until you sign in again; the greenhouse stays in the account.
It was an emailed 6-digit code, for a good reason — a code behaves the same in
a browser, a home-screen app and a native app, where a magic link opens in
whichever browser the mail app prefers and fails there. What that did not
survive is that Supabase only sends the code if the Magic Link template
contains {{ .Token }}, and sends a bare link until it does. The code path was
broken in production for everyone without a Google account; a password needs no
mail server.
The device still keeps a copy, and that is the point of it: the app opens
at once, works with no signal, and a photo taken on a balcony with no bars
saves locally and uploads on the next sync. The account is the source of
truth; the device database is the working copy. A failed token refresh does
not eject a keeper back to the welcome screen — only a real sign-out does
(useAccount in src/lib/auth.ts), because being thrown out mid-task is
exactly what shouldn't happen to someone photographing a plant offline.
A phone that signs into a different account keeps its plants and gives that account its own copy (the first account's copy stays as it was).
How sync works (src/lib/sync.ts): every local row has a uuid the
server keys on and a dirty flag that each write sets. A sync pulls rows
the server has changed since the phone's cursor (last-write-wins by
updated_at; a local edit at least as new stays and pushes next) and then
pushes every dirty row, plus tombstones for deleted plants. It runs on
launch, when a screen regains focus, and shortly after every write. Photos
upload once to the plant-photos bucket under <keeper>/<plant>/<photo>.jpg;
another phone shows them from that URL. Only accounts with an email sync;
an anonymous session couldn't be signed into elsewhere, so there'd be
nothing to promise.
On the web those photos are fetched through /plant-photos/<path>, which
Vercel rewrites to the bucket (vercel.json; Metro does the same in dev).
They can't be loaded from Supabase directly: the app sets
Cross-Origin-Embedder-Policy: require-corp for expo-sqlite's wasm, and that
blocks a cross-origin image whose response has no
Cross-Origin-Resource-Policy — which Supabase Storage doesn't send. Own
photos are local files and were never affected, so this only ever showed up
on a second phone, as a greenhouse with no pictures.
The bucket's four policies are worth keeping straight, because removing one
broke uploads for a day. A keeper may insert, update, select and delete inside
their own <keeper>/ folder, and nothing else — select included, because the
storage API reads back the row it writes, so without it every upload is refused
with "new row violates row-level security policy". There is deliberately no
policy granting anyone a view of the whole bucket: that was the enumeration
hole. Downloads need no policy at all, the bucket being public, which is why
losing select broke only new photos and left existing ones showing — and why
it took a day to notice.
Privacy note: the bucket is public-read (tags need it) with unguessable paths. Unpublishing a tag stops the link resolving but leaves the keeper's photos in place — they're the synced copy now — so someone who saved a photo URL while the tag was up keeps it. A private bucket with signed URLs would close that; it's a later change.
One-time setup for Google sign-in:
https://plant-care-flame.vercel.app, plus
any custom domain — list both, don't swap one for the other (see
Domain below). Authorized redirect URI:
https://ixagjvntbgyqemxxinqe.supabase.co/auth/v1/callback — that one is
the Supabase callback and doesn't change with the domain. Copy the
Client ID (….apps.googleusercontent.com) and the Client Secret.https://plant-care-flame.vercel.app; add
https://plant-care-flame.vercel.app/** to Redirect URLs. A custom
domain needs its own /** entry here too, added alongside rather than
in place of this one.Signups are on (Authentication → Sign In / Providers → Email → Allow new
users to sign up). They were off until Sep 2026, which meant nobody new could
join by any method — a live signUp answered Signups not allowed for this instance, project-wide rather than per-provider, so the emailed-code path
could never have made an account either.
"Confirm email" is off, so signUp returns a live session and sends
nothing from Supabase. It has to stay off until SMTP is configured: with it on, signing up
waits on a confirmation email, and the built-in mailer both rate-limits to a
few an hour and refuses any address outside the project team — so for a real
keeper it never arrives. The cost of off is that someone can sign up with an
address they don't own, which will matter once a tag is an identity and
doesn't yet.
The password policy requires a lowercase letter, a capital and a number,
minimum 6. The field's hint asks for all of it so it's right the first time,
but signInOrUp only checks length (8) — the policy is a dashboard setting,
already relaxed once, and a copy of it hardcoded here would start rejecting
passwords the server accepts. Supabase's refusal lists every acceptable
character; it is rewritten into one readable sentence.
Verified end to end against the live project: an unknown email is refused, signing up returns a confirmed session with no email sent, the password then signs in, a wrong one is refused and reported as a wrong password rather than a failed signup, and a weak one is refused legibly. The account made for that was deleted afterwards by id.
Account emails: welcome and password reset (Oct 2026). These don't go
through Supabase's mailer, which only delivers to the project's own team
without custom SMTP. The account-email edge function sends them itself,
through Resend, from [email protected], with replies going to the
contact address on /privacy.
welcomeOnce in
src/lib/auth.ts); the function sends it once per account, ever, and only
in the account's first week. Accounts that existed before this shipped were
marked as welcomed by the migration, so nobody was written to out of the
blue.plantparlour.org/?reset=<token>. The token comes from
auth.admin.generateLink({ type: "recovery" }) and is only spent when the
keeper presses Save and sign in (verifyOtp, then updateUser), so a
mail scanner that opens every link can't use it up first. The form answers
the same whether or not an account exists.claim_account_email():
3 resets an hour and 6 a day to one address, 10 an hour from one IP, 200
emails a day across the project. A claim is handed back if Resend refuses
the email. Addresses and IPs are stored hashed.plantparlour.org,
kept in Supabase Vault as resend_api_key and readable only by the service
role (public.resend_api_key()). To rotate it: make a new one in Resend,
then select vault.update_secret((select id from vault.secrets where name = 'resend_api_key'), '<new key>');.resend._domainkey,
and send MX + TXT) before Resend will send from it. Click and open
tracking are off on it, as the privacy page says.Deleting an account (Oct 2026). Account → Delete account, then type
DELETE. The delete-account function empties the keeper's folder in the
photo bucket, then calls delete_account(), which in one transaction folds
their AI costs into ai_usage_retired (per day, no keeper) and deletes the
auth.users row; every per-keeper table cascades from it, and
bug reports are first copied, without the keeper, into bug_reports_retired. A second storage sweep catches
an upload that was in flight. The app then wipes its own copy
(wipeLocalData) and the per-keeper AsyncStorage keys, and signs out
locally — without that, the next account to sign in on the phone would be
handed the deleted keeper's plants. It also needs a sign-in from the last
30 minutes, read off the token's amr claim (_shared/recent-sign-in.ts):
a phone's session lasts weeks, and an unlocked phone shouldn't be enough.
Checked live by e2e/delete-account.e2e.test.ts, which makes its own account
and deletes it -- while sign-ups confirm themselves; with "Confirm email" on
(since 18 Oct 2026) it can't get an account and skips, saying why.
Project AI spend is now ai_usage plus ai_usage_retired, and the day's AI
cap counts both.
Custom SMTP in Supabase is now only needed if "Confirm email" is ever turned
on: Authentication → Emails → SMTP Settings, host smtp.resend.com, port
465, user resend, a Resend sending key as the password, sender
[email protected].
src/domain/species.ts knows ~275 houseplants and ~170 named cultivars and
variegations — Thai Constellation, Albo, Marble Queen, Pink Princess, Tineke,
Raven ZZ — each with the reminder cadences that suit it. Type in the species
box and it suggests as you go (a genus prefix leads with scientific names, a
nickname like "thai con" or "pothos" resolves too); Browse opens the whole
list by group, with a Variegated & cultivars filter. Picking one, or typing
a name it knows, sets the plant's reminders and stores the catalogue's
spelling (Monstera deliciosa 'Thai Constellation'). Cultivars inherit their
parent's care and sit right under it in the list.
/plants — a care page for each of the 320 plants the catalogue knows, and
the only part of the site a crawler or an answer engine can actually read
(every app route renders the same empty shell and paints itself from the
bundle). Each page covers watering and how to tell when it's due, light,
humidity, soil, feeding, repotting, propagation, toxicity to cats and dogs,
what usually goes wrong and the fix, and the plant's named cultivars.
The cadences on those pages are read from src/domain/species.ts — the same
file the species picker uses — so the library and the reminders can never
disagree. The care knowledge that a cadence can't carry lives in
scripts/plants-data.mjs, at genus level with per-species overrides.
npm run plants # regenerate public/plants/ and sitemap.xml
npm run seo # check titles, canonicals, JSON-LD, FAQ mirroring, links
The generated pages are committed, so a change to a plant shows up as a diff.
Commit scripts/sitemap-manifest.json with them: it is what keeps each URL's
lastmod honest instead of claiming all 165 changed on every build.
The whole approach — the AEO furniture on every page, why tags stay
unlisted, what is deliberately not done yet — is written up in
docs/SEO.md.
On the add-plant screen, Identify with AI names the plant from its photo (top candidates with confidence; tapping one fills the species and sets its reminders from the catalogue) and reads its health. On a plant's page, Check health with AI does the health read on the latest photo, and any finding can be logged as an issue in one tap.
It runs in supabase/functions/analyze on Claude. Photos are shrunk to
1024px before they're sent. Only signed-in keepers can call it, and each
keeper gets 20 analyses a day (ai_usage table; the function alone writes
it).
Cost is a dial, set as function secrets with no redeploy: AI_MODEL
(claude-opus-5-5 default since 4 Oct 2026, $4/$20 against claude-opus-5's
$5/$25; claude-sonnet-5 at a third of the cost;
claude-haiku-4-5 cheapest) and AI_EFFORT (low | medium default |
high). Measured on five real photos: Opus named all five, cultivars
included (Thai Constellation, White Princess, Euphorbia 'Rubra'), at about
3¢ and 12–20 s each; Sonnet got two, calling the Thai Constellation an Albo
and a philodendron a pothos, at about 1¢ and 9 s. The image is most of the
cost, so caching doesn't help. Each answer reports which model and effort produced it and the tokens it
used. A request may also name a model from that list to compare answers on
one photo; the daily cap bounds it.
One-time setup: add your Anthropic API key as a function secret —
Supabase dashboard → Edge Functions → Secrets → ANTHROPIC_API_KEY (or
supabase secrets set ANTHROPIC_API_KEY=…). Until it's there the buttons
show a clear "not set up" message rather than failing quietly.
Each plant's page shows a care guide for its species — light, water, humidity,
soil, feeding, repotting, the problems it's prone to, and toxicity to pets and
children. Guides are generated once by Claude (supabase/functions/care,
Sonnet by default) and shared: the care_cards table caches each species,
so the first keeper to look one up pays a couple of cents and everyone after
reads it free and instantly. The card is also cached on-device, so it shows
offline after the first view. Set AI_CARE_MODEL to override the model.
Add to calendar on a plant's page turns its schedule into recurring
reminders your phone's Calendar app understands — a repeating event for each
of watering, fertilizing, repotting and a fresh photo, on that plant's own
cadence, first ping at 9am local. Each reminder carries the species care
instructions in its notes, so the alert tells you not just that the Thai
Constellation needs water but how to water it. On the web it downloads an
.ics (which phones open straight into Calendar); on a device it opens the
share sheet. The generator is src/domain/calendar.ts (pure, unit-tested);
src/lib/calendar.ts does the platform save.
Push notifications ("water your Monstera", "time to reorder soil") are the next step and need the native app — the web page can't ping a locked phone.
npm install
npm start # then scan the QR code with Expo Go on Android/iOS
npm run android # or open on a connected device / emulator
npm run web # runs in the browser too
Expo Go is the fastest way to put a build on a phone — yours or a friend's — without an app store.
Web: https://plant-care-flame.vercel.app — the permanent address every
published tag points at, and the whole app in a browser,
data kept on that device. On a phone, "Take photo" opens the camera and
"Add to Home Screen" gives it an icon. Photos are stored inside the app's
database (shrunk to 1600px), so they survive reloads. Hosted on Vercel,
which redeploys automatically on every push to main; no command to run.
The Supabase URL and publishable key are built into the bundle, so any host
serves a working app.
Native build: npm run start:tunnel runs it through Expo Go, scannable
from anywhere. Only needed for what a browser can't do — push notifications,
when they land. Leave it running while people are testing; when it stops, so
does the app on their phones.
The app answers on its Vercel address, and EXPO_PUBLIC_SITE_URL points it
at a custom one. Two facts shape the whole switch:
A published tag link is permanent once it leaves the app. The database
stores only the token; tagUrl() rebuilds the URL from SITE_URL every
time it is shown, so the app itself follows whatever host is configured.
What cannot be rewritten is a link already texted to someone or printed
onto a plant tag. Until such links exist, switching hosts is free — after
that, the old host has to keep answering, so add a domain rather than
replace one and check what is actually published before retiring a host:
select count(*) from plants where is_public and deleted_at is null;
EXPO_PUBLIC_SITE_URL is inlined at build time. Setting it in Vercel
fixes the web app on the next deploy. Phones keep minting links from the
value compiled into their build until a new binary ships, so the rollover
is gradual, not a cutover.
In order, and safe to stop after any step:
curl -sI https://<domain>/ | head -1 → HTTP/2 200.
Both hosts work now and nothing else has changed.https://<domain>/**
to Redirect URLs, keeping the existing entry.https://<domain>
to Authorized JavaScript origins, keeping the existing one.EXPO_PUBLIC_SITE_URL=https://<domain>,
then redeploy. New tag links published from the web app now use it.tag → secrets: SITE_URL=https://<domain>,
so old /functions/v1/tag?t=… links forward to the new host.SITE_URL's fallback in src/lib/supabase.ts is the live
domain, so a rebuilt app is correct without any variable set. Reload the
app and publish a tag to confirm the link comes out on the right host.Steps 3 and 4 are adds, not edits: replacing the old values locks out anyone mid-session and breaks sign-in from the old host while it is still live.
npm test # domain logic (care scheduling, alerts, keeper history)
npm run typecheck
npm run smoke # exports the web build and drives it in headless Chromium
npm run e2e # publishes a real tag to Supabase and syncs two "phones"
The care rules live in src/domain/care.ts and are pure functions with no
framework or storage dependency; care.test.ts pins them against fixed
dates.
npm run smoke walks the whole v0 flow in a real browser — add a plant,
see what's due, log care, report and resolve an issue, take a cutting,
reload — against the SQLite-on-wasm build. It needs a browser once:
npx playwright install chromium (or point CHROME at an existing binary).
Camera flows are checked on a device.
npm run e2e is the v1 counterpart and hits the real Supabase project in
.env: it builds a plant with a care history and a photo, calls the app's own
publishTag, then checks the snapshot landed, the photo is publicly
readable, the tag page renders, republishing reuses the link, a cutting
links back to its mother, the tables stay unreadable anonymously,
unpublishing returns a 404 while the keeper's own copy of the photo stays in
the bucket (the privacy note above), and republishing brings the same link
back. It deletes the rows and objects it created afterwards, by id.
It drives src/lib/tag.ts and src/db directly under Node — SQLite is
backed by node:sqlite and the two React-Native-only modules are stubbed
(e2e/stubs, wired up in vitest.e2e.config.ts) — so the publish path under
test is the one that ships. It needs PASSPORT_E2E_EMAIL and
PASSPORT_E2E_PASSWORD: anonymous sign-ins are off (see above), so there is
no identity without them. It leaves that account's own plants and its
greenhouse name as it found them.
e2e/sync.e2e.test.ts is the sync counterpart: two node:sqlite databases
play two phones on one account. A pushes a greenhouse with a photo and a
cutting; B pulls it and gets the same plants, history, lineage and a photo
that loads from storage; a change on B reaches A; when both edit the same
plant the later edit wins on both; a delete on A disappears from B; a third,
fresh phone ends up identical to A. Sync needs an account with an email, so
this one needs PASSPORT_E2E_EMAIL / PASSPORT_E2E_PASSWORD set to a
confirmed account (without them it tries a throwaway sign-up, which works
only when the project doesn't require email confirmation).
A keeper claims a handle on the account screen and their published plants get
one page — their conservatory — at plantparlour.org/@handle, served by
api/conservatory.ts the same way
/tag is — static HTML, one SECURITY DEFINER RPC with the publishable key,
no app boot, and og: tags so a shared link unfurls. Each plant on it links to
its own tag; there is no other way into a plant from there.
The vocabulary: the parlour is the private room a keeper keeps their greenhouse in, and gives the app its name; the conservatory is the glass house they show people into. What's on show (from the account screen) decides which plants are in it.
It publishes nothing new. conservatory() returns only rows already marked
is_public, which is the same set the tag links expose — the handle just
gathers them. A private plant is as invisible here as everywhere else, and the
function is an allow-list (jsonb_build_object), so a column added to plants
later is private until somebody decides otherwise.
The handle's rules live in the database, not only in the app: a check
constraint for the shape (^[A-Za-z0-9_]{3,20}$), another for the reserved
names (admin, tag, plantparlour, the route names…), and a unique index
on lower(handle) so @Amanda and @amanda can't be two people. HANDLE in
api/conservatory.ts and HANDLE_RULE in src/domain/handle.ts are copies for the
app's benefit, and a test checks they agree with the constraint.
Following, in the register the rest of this is written in: you leave your card at someone's conservatory and it joins your rounds (Account → Your rounds).
A card grants nothing. The rounds read exactly the published plants a
stranger sees at /@handle — a private plant is as invisible to someone
holding your card as to anyone else — so no policy on plants, photos or
care_events moved to build this. That was the point of the ordering: the one
sharing feature that existed before had a cross-tenant hole in it, and a
feature that hands out nothing has nothing to leak.
calling_cards is RLS'd to its owner, so the graph of who follows whom isn't
something the publishable key can walk. Leaving a card needs the other keeper's
id, which RLS rightly hides, so it goes through leave_card(handle) — handles
are public by design, being in URLs, so resolving one leaks nothing /@handle
doesn't. rounds() is an allow-list like conservatory().
Verified against the live project: with no session, rounds() is empty and
leave_card is refused; following is not mutual; a card at your own
conservatory is refused; an unknown handle is refused; and with a follower
holding a card, every photo the rounds hand back belongs to a published plant
(photos_from_private_plants: 0). The card that needed was deleted afterwards.
Finding someone. Your rounds looks a keeper up by their exact handle, or a
pasted conservatory link, through conservatory() — so the card comes after
you've seen whose it is, and a typo lands on "nobody by that name" instead of
in your rounds. A handle finds anyone; listing doesn't make a conservatory
public, it already is.
The directory (Account → Browse conservatories) is for browsing without a
handle. Listing is a switch on What's on show, off by default: a handle
claimed to send one friend a link is not a request to be browsed. A listed
conservatory with nothing on show stays out. conservatory_directory(q) is
an allow-list over the same is_public rule, for signed-in keepers only, and
searches handle, display name, and the names and species of what's on show,
with the LIKE wildcards escaped. e2e/directory.e2e.test.ts checks all of
that against the live project.
What a card is for. A card remembers when you last visited
(calling_cards.seen_at, set by visit_round(handle) when you tap Visit),
and rounds() counts what has gone on show since -- plants put on show, and
new photos of plants that were already there -- and sorts by the latest
change. The keeper sees how many cards their conservatory holds
(cards_left_with_me(), on What's on show), and so does its public page
(conservatory() returns cards). A number only: who holds whose card stays
private. Timestamps that decide "new" can't be set in the future
(plants_published_not_future, photos_created_not_future).
The keepers row. Adding listed turned up a hole (18 Oct 2026): the
table-level grants let a keeper write every column of their own row,
ai_unlimited and photo_calls included, or delete it to start the photo
trial over. Writes are now column grants — display_name, handle,
listed, updated_at (and id, which PostgREST's upsert repeats) — and
there is no delete; the counters move only through the edge functions'
SECURITY DEFINER calls. Nobody had used it: the one ai_unlimited row is
the owners'. The same e2e file asks the API to make those writes, putting
back the values already there, and expects 42501.
/parlour-games is a corner of the site with puzzles in it. It exists because
PlantParlour's long-term value depends on keepers coming back and a plant app
only genuinely needs you every few days; the games fill the days in between
without inventing fake plant chores.
They are plain static HTML under public/parlour-games/ — no bundle, no
sign-in, and one small shared script each — so they load on a bad connection
and a stranger can play one without an account. There are two.
Trickle: rotate the pipes until the water reaches every plant. Windowsill: arrange plants on a stepped shelf so each gets the light it wants, where a tall plant at the glass shades two rows back and a low one shades nothing, because it stands exactly as high as the step behind it.
Both have a daily board everyone shares, a practice mode, three difficulties
or sizes, and an embeddable build at /parlour-games/<game>/embed — the only
routes on the site allowed to be framed, everything else sending
frame-ancestors 'none'. Progress syncs through game_progress, keyed
(user_id, game) and RLS'd to its owner like everything else.
The streak is one run across the whole corner, not one per game: playing either of them today keeps it alive, and the hub is where it is shown. It lives in the harness where no game can reach it, because the version that lived inside Trickle dropped it on every pull for as long as it existed.
What the games share lives in public/parlour-games/harness.js, loaded from
that absolute path because each game is served at several URLs and a relative
src would resolve differently at each: the calendar the daily boards are
counted from, the streak, the account sync, night mode, the sound engine, and
the storage wrappers. It is the only thing a game fetches; everything else
stays inline. Its pure rules are unit-tested in harness.test.ts, which is
what the extraction was for — the streak merge that lived inside Trickle
dropped a signed-in keeper's streak on every pull, and nothing could see it
until the code came out where a test could reach it.
game_progress is keyed (user_id, game) with one opaque progress blob per
game; the reserved name parlour holds the streak, which is shared across
every game rather than kept per game.
The way in from the app is two links in the greenhouse: a card shown only when nothing needs attention, and a permanent one at the foot of the plant list. Both point at the hub rather than at a game, so the destination stays right as games are added — a link shipped in a released build is expensive to repoint.
The house rules, stated on the hub: no timers, no scores, nothing to lose. A game that punishes you is a game people quit, and coming back is the entire point.
Roadmap and what is planned next: docs/PARLOUR_GAMES.md. The next game is
specced in docs/windowsill-spec.md, with its design claims reproducible via
python3 scripts/windowsill-model.py.
/ is the welcome: what PlantParlour is, who made it, and the sign-in. It is
the page a stranger lands on, so src/app/+html.tsx carries the description
and og: tags — that markup is what unfurls in a chat thread when someone
passes the link on, and it has to be in the document rather than rendered by
the bundle.
There was a second copy served as static HTML at /welcome, which rendered
before the bundle downloaded and carried its own og: tags. The cost was the
same words in two places, and they drifted apart within a day of being
written. /welcome redirects to / now, so links already shared still work.
If first paint on a cold phone ever becomes the thing that loses people, the
static page is in the history at api/welcome.ts and worth reviving — but
then the copy needs one source, not two.
Type on this screen runs larger than the rest of the app (17px body, 19px headings). Fifteen-point body is right for a dense screen someone uses every day, and small for a page a stranger reads once while deciding whether to bother.
The brand from Amanda's brief, as settled on the design canvas: an
aubergine page with cream cards and aubergine text on them, gold as the one
working colour, plum for the water drops and "Log", a cooler burgundy for
"something's up". Lora (the tag page's serif) for titles and section labels,
Source Sans 3 for everything else. src/theme.ts holds two palettes with
one shape — on the page and inside a card — and useTheme() returns the
right one for wherever a component renders (Card sets the surface), so
every colour pair stays at or above WCAG AA.
src/app/ Expo Router screens
index.tsx the greenhouse (plant list, sorted by what needs attention)
plant/new.tsx add a plant
plant/[id]/ plant detail (care, photos, lineage, history) and edit
account.tsx sign in with an email code; sync status
src/domain/care.ts care scheduling rules — the product's brain
src/domain/calendar.ts the .ics reminder feed
src/db/ SQLite schema and typed queries (plus the sync-facing ones)
src/lib/ sync engine, auth, tag publishing, AI, photos, calendar save
src/components/ small UI kit + plant card
e2e/ live tag publish and two-phone sync checks (npm run e2e)
docs/PRODUCT.md product brief and roadmap
docs/COMPETITION.md where we stand against the other plant apps
docs/PRICING.md what a keeper costs to serve, and what to charge
A first cut of the whole thing — including sharing, tags and trades —
was prototyped inside a Shopify app (d13-ai/plant-compliance-app, branch
claude/plant-care-tracking-xa0dnq). The domain logic here is ported from
it; the UI and the shop-as-identity assumption were left behind.