00 — How the work moves
Every sprint starts with something measured.
This is an active project, still shipping. It doesn’t run on a release calendar. It moves when a test comes back and says what to build next. Four of those tests set the direction this case study describes, and two of them came back saying the plan was wrong.
Can vision read a handwritten roster?
Said: yes, unreliably. Built: the honesty layer before the accuracy layer. Freshness states, redaction, an archive.
Does anyone actually want this?
Said: 193 forum posts, counted and ranked. Availability first, water second, road access third. Built: that ranking, in order.
Should a different reader parse the sheet?
Said: 95.7% on one sheet; 4.1% on eighty-six. Built: not the reader, but the one part of it that held up on the real photos.
Can any of it be found?
Said: every campsite URL was quietly returning the homepage. Built: 91 crawlable pages, and a smoke test that checks the premise instead of the status code.
01 — The Problem
Ground truth lives on a physical sheet.
Green Ridge doesn’t expose a reservation API — the Forest Service manages a first-come, first-served signup sheet at headquarters, staffed during limited hours. The operational ground truth lives on that physical board. Campers want planning context (what kind of site is this?) and situational context (what did the board say recently?). A polished “availability” UI that pretends to be authoritative would erode trust faster than no app at all.
The design problem became: help people browse and compare sites honestly, surface freshness of clipboard-derived state — including when the last photo is too old to assert open or taken — and make contribution at HQ feel fast and cooperative, not extractive. Always confirm at headquarters; the app shows what’s likely taken before you drive out.
The clipboard at headquarters is a single physical artifact. It lives inside one building, during posted hours, and it can only be read by someone standing in front of it. There is no digital backup, no historical record, and no way to query it remotely. If you want to know what’s open, you drive there. That constraint shaped every product decision that followed.
02 — Design Process
Full ownership, no handoffs.
The build wasn’t sequential phases — design, then engineering, then ops. It was concurrent decisions made by the same person. Four choices shaped everything.
The artifact defined the architecture
The physical signup sheet at HQ didn’t just supply data — it supplied the mental model campers already use on-site. That constraint drove the information architecture more than any wireframe: the grid they reason about at headquarters had to be the grid they see in the live app. Site detail leans the same way — full-bleed photography and traits so a campsite reads as a place, not a row in an inventory table.
Honesty as a design primitive
Every availability state — fresh, stale, warn, archived — had to be designed twice: once for browsing (map and sites list) and once for contributing (the clipboard flow). Uncertainty is the product condition, so freshness language became first-class chrome: a map chip shows Availability · {age} and nudges “Update” when the HQ photo ages out. Past the warn threshold the product refuses to assert open or taken — preview copy degrades to Availability unknown · last HQ photo… rather than guess wrong. See the states in Storybook and on the live map.
Ship the smallest thing that can be wrong in public
Nothing here was validated on a prototype first. The first build went live in a day: map, sites, auth, and a photo of the clipboard turning into rows. The only way to learn whether vision could read a handwritten roster was to point it at real ones. Production code is the prototyping medium, and the cheapest way to be told you are wrong by something other than your own judgment. Every arc since has opened the same way, with a measurement rather than a backlog.
Coherence was what no handoffs bought. One semantic token system kept the map, the admin tools, the marketing pages and the native shells telling the same story while the product kept moving underneath them. Nothing had to be re-specified for a second team, because there was no second team. The two production-linked showcases that keep it honest are in 06.
The backlog came from a count, not a hunch. A scan of the Green Ridge subreddit classified 193 posts by theme and ranked them against what the app already did. Availability came first, by a distance. Water second, road conditions third, trailers eighth, which is why trailer and pop-up tags shipped in the same cycle the scan landed. One rule came with it and has held since: a fact reaches the app only if it clears a primary source, the DNR’s own page or the registration form at headquarters. Camper anecdote stays a lead in the research file.
03 — Field & Flow
The camper with a phone is the update.
Before the map asks anything of a visitor, the product teaches the loop. A first-run welcome and the in-app Guide say it in camper language: first-come camping, availability from HQ clipboard photos, contribution that reads site numbers and stay dates only, never names, and a plain line that this is not a Maryland DNR product. Always confirm at headquarters.
Photography is the refresh mechanism. Someone standing at the board captures what it says so everyone else gets a more current picture, without anyone claiming a reservation. Two entry points reach the same pipeline: the in-app update-from-clipboard flow, and a standalone photo-clipboard landing for share campaigns. The freshness chip deep-links here once the last photo goes stale.
Field constraints shaped the copy and the server both. Wording favours “photo” over “scan,” layouts assume one hand on a phone, and the checks that run before a model sees the image are the ones holding up trust: byte and dimension validation, optional EXIF capture date, an HQ proximity check when the metadata allows a fair one.
The operational artifact in the field: the product mirrors this object—not a reservation database. Tap the card to open the full clipboard photo flow.
04 — Vision & Backend
The model extracts. The server decides.
The vision layer doesn’t “guess reservations.” It extracts structured rows from a photo; the server validates JSON, applies privacy rules, and routes uncertain work to humans.
Multipart image
POST /api/sheet; Cognito-linked identity where required.
Validate bytes
Magic-byte check, strip/re-encode, dimension limits, optional preprocess before the model call.
Proximity check
EXIF GPS vs headquarters radius when present; skip when metadata doesn’t allow a fair check.
Structured JSON
Multimodal call returns rows, sites, dates — retries with backoff on bad output.
Schema + rules
Server schema validation; low confidence routes to pending review, not silent publish.
Redact + archive
Redacted public archive image; duplicate detection via content hash.
Human-in-the-loop: uncertain parses surface in admin review — trust is a workflow, not only a model score. Admin-validated snapshots feed back into the model as a curated training corpus.
05 — Accuracy Loop
Every correction is a training signal.
Every admin-validated snapshot is a training signal. Corrections made in the review UI feed directly into re-parse quality and the fine-tuning corpus — no separate annotation workflow.
Row-by-row editor
Approve, correct dates, split confident vs. uncertain. Archived photo with zoom/pan.
Anchor + re-run
Validated rows injected as human-verified anchors; model surfaces missed sites.
Build the corpus
Snapshots marked for export build the corpus with production preprocessing.
JSONL → deploy
JSONL export → OpenAI fine-tune → model ID via OPENAI_SHEET_MODEL.
The loop had to be pointed at itself first. The bench that graded this pipeline re-parsed earlier snapshots and scored them against their own published rows: a regression canary, not an accuracy measure, and structurally unable to see an error that had already gone live. Hand-labelling started from there, and the gold set is still small enough that saying so is part of the claim. One sheet. That is why the comparison work in 06c runs in shadow against real uploads instead of against a benchmark.
The archive becomes a calendar. Historic parses feed an admin booking-patterns view: seasonal density and relative site demand from the same board, framed as a relative guide, not a live forecast. Camper-facing surfaces never pretend a heatmap is a reservation.
05b — Out on the Trail
The blur is the honesty.
The availability engine taught one discipline above all others: name uncertainty, don’t launder it. The surfaces built after it carry that past the clipboard, into everything a camper actually wonders about once the tent is up.
A camp-chair companion, not a biology course. The guide grew into what a first-time visitor actually needs. 121 species with photographs and credits, browsable by month rather than by season, so August says what is new this month and what is in August for the last time. Ten chapters on how the ridge got here and who was on it.
A first night here is loud in ways newcomers don’t expect. A red fox that screams like a person. The public range carrying on a still morning. Sixty-seven recordings under Creative Commons ship inside the app, so the answer arrives without signal. On the map a soundshed shows how far the range travels, and it is deliberately soft-edged. Nobody knows how far a shot carries on a given morning, so the fuzzy corridor says exactly that, its reach calibrated from acoustic research and from what campers wrote in their own reviews. Interstate 68 and the Potomac freight line get corridors of their own, so someone choosing a site can see which ones hear what.
Location meets the camper where they stand: distance and heading to a site, the nearest trailhead, a quiet you’re at Site 27 the moment they arrive. Foreground-only and on-device, which is a deliberate refusal of background tracking rather than a limitation.
None of it needed new infrastructure. Distance reuses the map’s geometry, the soundshed reuses its overlay system, the guide is a data file and a page off the same tokens. New doors, same house.
05c — The Night Sky
The useful question is not whether it is night. It is whether it is dark.
Plenty of apps will tell you which planets are up. A phone in a suburb answers that just as well. What a forest actually offers is darkness, and darkness is only worth anything for the things that need it, which is the reason someone drives two hours out in the first place.
Three things spoil a dark sky, and all three are computable. The sun has to be far enough down that the last of the twilight has gone, eighteen degrees below the horizon. The moon has to be down as well, because a full moon at midnight washes out everything but the brightest meteors and no clear-sky forecast will mention it. So the app works out the moonless dark window rather than reporting sunset and sunrise and leaving the rest to the visitor.
The third spoiler is the ridge you pitched under. The terrain model already knows the skyline from any given site, so the chart is clipped by the horizon that is actually there: an open-sky fraction computed per pitch, and the clearest direction named only when it is meaningfully better than the rest. The night over a chosen pitch, not over a town forty miles away.
The catalogue is naked-eye and honestly graded. A galaxy, a nebula, a couple of clusters that stop being a smudge and start having shape. Each object states what it needs, a genuinely dark moonless night or merely a decent one, so nothing quietly promises something that will disappoint. M13 sits at magnitude 5.8 and is marked as reachable from a dark site rather than sold as easy. And because finding a faint fuzzy is a navigation problem rather than a brightness problem, each one names the bright star you hop from, with a test asserting every one of those names exists so a rename cannot leave a direction pointing at nothing.
It is the freshness chip again, pointed upward. Say what is actually knowable from here, tonight, and decline to imply the rest.
06 — Surfaces
One system, many doors.
Four product doors and two design showcases pull from one token system, so no surface can drift from production. The publish path is part of the product: one web deploy updates the live PWA and every Capacitor shell pointed at it.
UX & Design Kit
Two showcases, one system
A curated explorer and a full workshop, both importing production components, with a parity check across the two so a documented component and a shipped one cannot quietly disagree.
Engineering & Ops
Cognito → Caddy → Node
Sheet uploads, vision parse, snapshot lifecycle, admin review, and booking-pattern heatmaps on a Cognito-protected API; Caddy → Node on Lightsail. Pipeline detail on the product case study.
Marketing
Same host, different door
Standalone HTML landing with OG cards, hero narrative, and TestFlight interest capture on the same host API.
Live product
PWA + Capacitor
Freshness chip, Guide howto, site detail, and clipboard capture, over a map whose default view is now a terrain model of the forest: real hydrography, true sun and moon position, seasonal colour, with the flat map one tap away. Capacitor shells use a remote-URL model. The store binary is a frame; deploy-web swaps the painting and publishes a self-hosted OTA bundle, so installed phones and browsers stay in lockstep without a store review.
Move the front door without closing it. The product outgrew its first host and moved to its own domain across six phases under one rule: additive, never destructive. Nothing that already existed stopped working. The old host served in parallel, then as a permanent redirect, and every phase reverted on its own. The same release added 91 crawlable campsite pages behind a content guarantee that holds with JavaScript off, after a pre-flight check found every one of those URLs quietly resolving to the homepage.
Publish once, reach both doors, then prove it in a real browser. One deploy-web sends the SPA to Lightsail; the PWA and the Capacitor shells reload the same production URL, so interface changes land in hours instead of a store review. What follows is not a headless screenshot pass. Claude in Chrome drives scripted journeys across the live app, the kit, the capture flow and the API, and returns pass or fail before a change is called done.
06b — Figma Library
The source library behind the shipped kit.
The Figma library holds 34 component sets and 22 screens, kept in parity with the production Storybook — tokens and components flow from the working code back into the design canvas, so the library can’t drift from what shipped.

06c — What Testing Killed
The number that looked decisive, and the number that killed it.
A different reader for the clipboard, AWS Textract instead of a multimodal model, looked like an easy win. On the one hand-labelled sheet it returned exact F1 of 95.7% at 100% precision against roughly 77.5% for the vision path on the same photo. Deterministic, fifteen hundredths of a cent a page, under seven seconds. That is the kind of number that ends an evaluation.
It shipped to production in shadow mode instead: reading every upload alongside the live path, recording the comparison, publishing nothing. The gold set was n=1, and one sheet is an anecdote with a decimal point.
Then a backfill replayed it across all 86 archived photos for thirteen cents, and the picture inverted. Forty-nine of the 86 returned zero rows. Agreement with admin-approved rows was 4.1% on imported historic sheets and 12% on real uploads. Where both readers read something, only 40% overlapped at the site level: they were reading different rows, not disagreeing about dates. The obvious explanation was resolution, so that got tested too, and banding the corpus by megapixel refuted it. Above 8 MP more than half still returned nothing. No threshold to gate on, and the gold sheet remains an outlier I never explained.
Textract did not become the reader. What survived is the part the handwriting verdict could not touch: page registration worked on 86 of 86 sheets, every resolution band, every sheet format from 2021 to 2026. It only needs the printed furniture, so it stayed, as the geometry behind the review overlay. A test that kills a feature is still allowed to ship something.
The bench was grading itself
The eval re-parsed earlier snapshots and scored them against their own published rows. A regression canary, useful for catching drift, structurally incapable of seeing an error that had already gone live. Finding that out was worth more than any score it had ever produced.
The model never sees your pixels
Profiling all 48 local sheets moved the accuracy effort onto a different axis. Exposure was fine; one sheet in 48 was genuinely dark. The binding constraint is per-row density, because the API downsizes every image to about 1568 px on the long edge, which leaves handwriting 8 to 12 px tall on the best inputs. Camera quality was never the variable. The contrast preprocessing that had been built was aimed at the wrong problem. Cropping for density is the obvious counter, and it is still a hypothesis: see 08b.
The comparison is public, and it is not the app’s code. paperparse is the same question extracted into something anyone can run: two interchangeable backends against a photographed handwritten form, the form described once as data rather than code, and a harness that says which backend to use. The live pipeline is a separate implementation, so this is the argument made inspectable, not the engine behind the product. On its synthetic corpus Textract reads at 92.2% row recall and 99.9% field accuracy, which is the point: a clean benchmark called it viable and 86 real photographs did not. Its README carries its own reversals, including the release where a change to row anchoring took the default backend to zero while every test kept passing. The viewer replays nine recorded runs against ground truth with no install and no key.
Three measurements, two reversals, one plan redirected before it cost anything. In a domain this uncertain the corpus gets a vote, and it does not care which answer was already written down.
07 — Takeaways
Four lessons that generalize.
State where authority lives
Booking stays on the sheet; the app describes last known state and freshness. When the photo is too old, it says unknown instead of guessing — the architecture refuses to overclaim what it actually knows.
In uncertain domains, provenance beats polish
Fresh, stale, warn, and archived semantics align map chrome, list, preview, and upload outcomes. Users can act because they can see how old the board photo is — and when not to trust a status line.
Domain feedback loops beat generic dashboards
Parsed rows, favourites, historic stays and admin seasonality all come off the same clipboard corpus, and together they answer “is this working?” better than any usage metric would.
AI as collaborator, runtime, and QA
Assistants accelerated the build, vision parsing makes the analog board legible within guardrails, and a browser agent walks the real journeys after every deploy. Three different jobs, one of which is checking the other two.
08 — Reflection
Uncertainty is the product condition.
Most product work assumes a stable source of truth and designs the interface around it. This one didn’t have that luxury. The truth lived on a piece of paper that only a few people could see at a time. Designing for that meant treating uncertainty as a feature of the system, not a bug — and building every layer to honor it: the first-run welcome, the map’s freshness chip, the preview that prefers unknown to a wrong status, the vision queue, and the dual design showcases that keep tokens and states coherent. The job is to make uncertainty understandable, useful, and shareable.
The thesis has held for five months because it was built to be tested against rather than argued for. Measurements kept coming back saying the plan was wrong. The bench that was grading itself. The nine campsites the map had confidently placed sixteen kilometres from where they actually are, until Maryland DNR’s own GIS was imported as the authority. Each cost less than the feature it prevented, and the method generalises past campgrounds. Where the truth is genuinely uncertain, the cheapest thing you can build is the thing that tells you which part of it you got wrong.
08b — Still Open
What is still open.
This is a live project, not a delivered one. Four things are unresolved as this page is written, and none of them carry a date. The work moves when there is time to move it.
The gold set is one sheet
Every accuracy claim here rests on a single hand-labelled roster. Shadow mode runs in production to replace that with real traffic. Until it has, the honest statement is that the pipeline is measured, not proven.
The native binaries lag by design
Installed apps carry a compiled host allowlist that an over-the-air update cannot change, so the last step of the domain move waits on how many people install the newer build. Not a deadline. An adoption curve.
The demand list is not finished
Four items the forum scan ranked are still unbuilt: a crowding forecast for the drive-out gamble, swimming and paddling access, a live burn-ban status that needs a source worth trusting, and a guided way to choose a site.
Whether cropping helps is unmeasured
The density finding says crop the form into bands. The open-source harness measured the opposite on synthetic pages, whole-page reads beating sectioning by sixteen points. Nobody has run that comparison on the real archive, so the pipeline reads whole pages and the theory stays a theory.
09 — What This Becomes
The clipboard is not a Green Ridge problem.
One forest was the test case. A retargetable version has been sized against five park archetypes, and sized the way everything else here gets sized: probed, not assumed. National forests, national parks, recreation areas, Virginia state parks, county parks.
Two findings break the product as built. A park may have no campsites at all, which means every campsite-shaped surface needs a no-camping mode where the availability interface is absent rather than empty. And the campsite is not the universal atom. An integer site number is load-bearing across 30 of 94 API modules, 61 of 136 components and seven per-user stores, and it has to become an opaque identifier for a place that might be a trailhead, a boat launch, an overlook or a cabin.
The finding that matters most is about where availability comes from. There are five kinds of source, and the interesting one is the fallback. Federal campgrounds answer a keyless API. Every Virginia state park is behind a reservation portal with no public API at all. Which means the technique built for one forest’s paper board is not a local workaround for a quirky park. It is the general fallback for everywhere that has camping and nothing machine-readable to ask.
It has started, carefully. A second park is imported and validated: Prince William Forest, 130 places pulled keyless from recreation.gov, five distinct kinds inside what that API calls five campgrounds, passing the same schema as Green Ridge with no special-casing. The tooling is deliberately not wired into the shipping app. The model has to hold against two real parks before a migration is allowed near production code.
The discipline came with it, which is the part worth reading. Provenance drives capability: every Prince William place shares a single facility centroid rather than a surveyed coordinate, so you are at Site 27 is simply unanswerable there, and the feature is off rather than wrong. The park sits about thirty miles from Washington, so its config records a Bortle 7 sky and grades the deep-sky list down accordingly rather than promising a galaxy that sky cannot show. The note in the file reads: preconditions are measured, not judged. A feature that is true but says nothing is a failure mode.