Inspiration
Every rainy season, Yunnan's wild mushrooms show up twice on the Chinese internet: once as food, and once as a joke. 见手青 — the blue-staining bolete that turns your fingers indigo — is famous for the hallucinations it causes when undercooked, and "seeing little people" has become a running meme.
The joke has a hospital ward behind it. A reference work published in 2023 catalogues 1,341 macrofungal species in Yunnan; the Kunming Institute of Botany describes more than 200 poisonous ones. Meanwhile the actual safety guidance lives in Chinese government PDFs that nobody reads before dinner.
We wanted to build something that holds both truths at once: these mushrooms are a genuine culinary treasure, and the risk is real. Most of the identification tools we looked at only do the first half, and the confident ones scared us more than the mushrooms did.
What it does
Yunnan Mushroom Awareness is a five-page site about the wild mushrooms of southwest China:
- Mushrooms — twelve local names you'll actually hear in a Kunming market (鸡枞, 干巴菌, 见手青…), with a further-reading list of scientific references.
- Safety — what to do if poisoning is suspected, why colour is not a safety test, and why a familiar name doesn't certify a specimen.
- About — our research, our sources, and the questions we could not answer.
- Identify — an image-upload flow that takes a photo of a mushroom and returns a species suggestion alongside what's known about its toxicity.
The identify page is the part we thought hardest about, and the constraint we set ourselves was unusual: it must never be readable as permission to eat something. The disclaimer is the first thing on the page, above the upload box, not buried under the result. The result card describes itself as "a suggestion from an image model, not an identification." A verdict is never rendered blank, because a blank verdict reads as fine.
The deployed site does not call a model. We trained a classifier during the hackathon, and it was not close to the standard we'd want before putting a result in front of someone deciding what to eat. So the page ships with the model disconnected and says so plainly, instead of inventing an answer. There is a separate, still-incomplete branch where we're continuing that work.
How we built it
Deliberately plain: hand-written HTML, CSS, and vanilla JavaScript. No framework, no build step, no bundler. Firebase Hosting serves it as static files. For a five-page site, a toolchain would have cost us more time than it saved.
A design system in one stylesheet. subpages.css is the whole thing: no CSS custom properties, no shadows, border-radius of zero everywhere except a 3px button. Depth comes from 1px borders and background tints. The constraint kept five pages looking like one site, and it made the newest page cheap to build — most of it is existing classes.
Research before markup. Every number on the site is traced to a source and cited inline: China CDC for the emergency guidance, the Kunming Institute of Botany for the poisonous-species count, Xinhua for the reference-work coverage.
A state machine for the identify page. The upload flow has six states — empty, ready, loading, success, unavailable, error. Rather than toggle visibility on a dozen elements per transition, JavaScript writes a single data-state attribute on one wrapper and CSS does all the showing and hiding. One atomic write per transition, and the current state is one attribute you can read in DevTools.
One integration seam. All model communication is confined to a single function with a documented response contract:
async function classifyImage(file) { ... }
Validation, preview, rendering, and the toxicity-to-styling mapping are all written against that contract and never touch the model. Building it this way had a payoff we didn't anticipate: when we decided not to ship the classifier, there was nothing to tear out. "Model not connected" is a first-class state in the machine, not an error path bolted on at the end.
Challenges we ran into
Numbers we had to throw away. Our original research notes listed "about 20–30 deaths and 2,786.2 poisoning cases per year." That decimal point should have been a warning. We couldn't establish the reporting period, the case definition, or the calculation behind it — so we didn't publish it as a statistic. The About page now has a section called "Questions we are still checking" that says this out loud.
A name is not a species. 见手青 isn't one mushroom, it's a common name covering several blue-staining boletes. Our notes had Galerina sulcipes (a typo for sulciceps), and two of our reference links turned out to describe a whole genus rather than the species we'd claimed. Each of those had to be caught by hand and labelled honestly on the page.
Drag-and-drop is much harder than it looks. dragenter and dragleave fire once per descendant element, so moving the cursor from the drop zone onto the label inside it makes the highlight strobe — we ended up tracking drag depth with a counter. That counter then sticks forever if you drag out of the browser window, so it needs hard resets in three places. And without preventDefault() on document, a photo dropped twenty pixels outside the zone makes the browser navigate away and replace the entire page with the JPEG.
Keeping the drop zone usable without a mouse. The tempting version is a <div role="button"> with a keydown handler. We used a real <input type="file"> hidden by clipping — not display: none, which would remove it from the tab order — inside a <label>, and drew the focus ring on the wrapper with :focus-within. The browser then provides click, Enter, Space, and screen-reader labelling for free.
The CSS cascade fought back. .page-wrap a is specificity (0,2,0) and silently flattens any single-class button style. .subpage p is (0,1,1) and quietly beats .identify-hint. And [hidden] doesn't hide the preview image, because our own .identify-preview img { display: block } outranks the browser's default stylesheet. Every one of these failed silently — nothing throws, the page just looks subtly wrong.
The last half hour. This was a two-day hackathon, and with about thirty minutes left we were still arguing about whether to connect the classifier. Shipping it would have made the demo obviously better. It would also have meant putting an unreliable answer about mushroom toxicity in front of a real person. We disconnected it, wrote a "not connected" state that looks deliberate rather than broken, and pushed with minutes to spare. It is the decision we're least sure looked good and most sure was right.
Accomplishments that we're proud of
- We published what we don't know. A section of the About page is dedicated to figures we couldn't verify. It would have been easier, and looked more impressive, to just print the numbers.
- We held ourselves to the standard we're asking readers to meet. The whole site argues you shouldn't act on an uncertain identification. When ours turned out to be uncertain, we didn't ship it.
- The identify page is genuinely accessible, not accessible-shaped: keyboard-operable upload, live regions for status and errors that stay in the DOM so they actually announce, focus moved to the results heading after the scroll, and
prefers-reduced-motionhonoured for the scroll itself. - Five pages, one visual language, zero dependencies. No framework, no CSS library, nothing to install.
What we learned
- Verification is slower than writing. Checking a single statistic took longer than building an entire page — and produced better work than the statistic would have.
- Vanilla CSS is a real skill. Working without variables or a framework taught us specificity properly, mostly by getting it wrong in ways that produce no error message.
- The File API has sharp edges — object URLs leak unless you revoke the old one before assigning the new one, and
FileReaderinflates an 8 MB photo into 11 MB of base64 for no benefit. - In safety UX, placement outranks wording. Moving the disclaimer above the upload box changed the meaning of the page far more than any rewrite of the disclaimer text did.
- Design the seam before you need it. We drew the boundary around the model for tidiness. It turned out to be what made a hard last-minute call cheap to execute.
What's next for public safety - group 1c
- Get the classifier to a standard we'd actually stand behind, and merge that branch when it's there — not before. Right now the honest status is "in progress," and we'd rather say that than dress it up.
- Calibrate for refusal, not accuracy. The most useful thing this model can output is "I don't know, ask a person." We'd rather tune for a high abstention rate than a high top-1 score.
- Move the species data into Firestore so toxicity notes can be corrected without a redeploy.
- A full Chinese version. The people most at risk read Chinese first, and right now they get English with Chinese species names sprinkled in.
- Fix the hosting layout. Our HTML currently sits in
public/html/, so the site deploys one directory deeper than it should.
Log in or sign up for Devpost to join the conversation.