Greenridge: A trustworthy mirror for an analog campground board.

Green Ridge campsites are first-come, first-served — ground truth lives on a clipboard at HQ. This case study documents how the app mirrors that board with visible freshness, honest unknown states, human review, a production-linked design system (interactive kit + Storybook workshop), and a publish loop that reaches the PWA and Capacitor iOS shell together…

Greenridge

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.

01Feasibility

Can vision read a handwritten roster?

Said: yes, unreliably. Built: the honesty layer before the accuracy layer. Freshness states, redaction, an archive.

02Demand

Does anyone actually want this?

Said: 193 forum posts, counted and ranked. Availability first, water second, road access third. Built: that ranking, in order.

03Accuracy

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.

04Findability

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.

First-come camping roster on a clipboard at Green Ridge headquarters.
HQ clipboard — see on product case study

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 shipped app · the forest in 3D, Site 50 in detail, then the nature guide. Live capture

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.

01Upload

Multipart image

POST /api/sheet; Cognito-linked identity where required.

02Hygiene

Validate bytes

Magic-byte check, strip/re-encode, dimension limits, optional preprocess before the model call.

03HQ Gate

Proximity check

EXIF GPS vs headquarters radius when present; skip when metadata doesn’t allow a fair check.

04Vision Parse

Structured JSON

Multimodal call returns rows, sites, dates — retries with backoff on bad output.

05Validate

Schema + rules

Server schema validation; low confidence routes to pending review, not silent publish.

06Store

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.

ML training export admin screen: snapshot list, HQ clipboard photo with zoom, editable parsed rows, checkpoint save, and JSON/ZIP download.
ML training export — inspect snapshots against the archived HQ photo, correct rows, checkpoint sets, export JSONL for fine-tuning.
01Admin Verify

Row-by-row editor

Approve, correct dates, split confident vs. uncertain. Archived photo with zoom/pan.

02Guided Re-parse

Anchor + re-run

Validated rows injected as human-verified anchors; model surfaces missed sites.

03Curate

Build the corpus

Snapshots marked for export build the corpus with production preprocessing.

04Fine-tune

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.

The nature guide on a phone: See, Hear, Land and Story tabs, a month histogram with August raised, category pills from trees to reptiles, a line reading 95 species you might spot in August, and an American Beech card.
The See tab, browsable by month. August knows what is new and what is leaving.

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.

Greenridge on a phone: the forest as a terrain model tilted to the horizon, campsite pins standing on the ground with headquarters among them, a river cutting the lower right, and a freshness chip reading Availability, 30 days ago, Update.
The map, as it opens today. The chip is the honesty: 30 days old, and it says so.

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.

Greenridge Figma library, screen index: the Camper journey flow showing map, sites list, site detail, guide and account thumbnails, each titled as a link to its frame.
Camper journey · one of six flows in the screen index. Each thumbnail is an instance of the library, not a redraw.

Open the Greenridge Figma library

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.

The paperparse viewer: a photographed camping roster with every row overlaid, green where a row was accepted and red hatching where it was dropped, beside counts reading 45 accepted, 0 review, 22 dropped, 14 never returned, and a panel explaining that nothing routed to review is not the same as nothing going wrong.
Nine recorded runs, switchable by capture condition. Green rows were accepted, red hatching was dropped, and the panel on the right says the part that matters: nothing was routed to review, which is not the same as nothing going wrong.

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.