Frame — a free, installable, offline-first PWA that answers one question for a bird photographer: where do I go, in which month, to photograph which birds? Hotspots are ranked by photographic opportunity (present and shootable), not by lister rarity.
- Production: https://bird-location-scouting.pages.dev
- Staging preview: https://staging.bird-location-scouting.pages.dev
- App source & full technical README:
frame/ - Release notes:
CHANGELOG.md(mirrored in-app under ⓘ → What's new, and to GitHub Releases)
The 58 California counties plus 5 western-Nevada counties (Douglas, Lyon, Storey, Washoe, Carson City — the Tahoe/Reno area). Every county carries its top eBird hotspots from the quarterly data build (El Dorado, Placer, Sacramento and Humboldt keep every hotspot); regions (sets of counties) are user-defined on the county map, up to 3 saved on-device.
opportunity(h, month) = Σ_species frequency × shootability over the working
species set, trust-shrunk and ranked within each month. Frequency comes from
real eBird histogram data where it exists and from a transparent habitat/season
model where it doesn't (always flagged inferred). shootability (v24,
frame/src/model/photo.js) is the transparent photo-first weight — two small
global tables over each bird's published facets (behaviour: open ×1 / mixed
×0.6 / skulker ×0.25; size: ×0.5–×1) — on by default, announced by a standing
camera chip with a one-tap "every bird equal" exit. The old hidden per-species
photoability judgment stays gone (v23): each hotspot shows objective facets
(type / size / nest / behaviour) as bright/subdued/faint icons keyed to real
frequency, the icons double as tri-state filters, and displayed numbers (the
"N birds likely" headline, every frequency) are never weighted — only the
order and colour intensity are (frame/src/data/species.js,
frame/src/data/facets.js).
A fully offline SVG basemap — no tiles, no connection needed in the field:
| Layer | Source | Notes |
|---|---|---|
| County shapes & names | US Census via plotly GeoJSON | generated by frame/scripts/gen-county-shapes.mjs |
| Rivers, lakes, coastline | Natural Earth 10m (public domain), incl. the finer North-America rivers layer | named on the map |
| Major roads | Natural Earth 10m roads | clay-red lines; numbered pill shields on major routes only |
| Parks / protected lands | Natural Earth 10m (NPS-scale units) | green fills, named |
| Reservoirs & lakes Natural Earth misses (Folsom, Natoma, Camanche…) | curated list in frame/scripts/curated-landmarks.mjs; shorelines © OpenStreetMap contributors (ODbL), fetched by gen-basemap.yml on a runner |
real water polygons + labels; every position is point-in-polygon verified against its county at build time — a wrong position fails the build |
| Wildlife refuges & preserves (Cosumnes, Yolo Bypass, Gray Lodge, the NWRs…) | curated, same verification | the landmarks that matter most in a bird app |
Deliberately not included: minor roads and street detail (the per-hotspot
"Maps" button hands off to a real navigation app), turn-by-turn anything, and
land-use layers without clean public-domain data (restricted areas, school
campuses — on the roadmap if a trustworthy source is found). All map data is
generated into committed files by frame/scripts/gen-*.mjs; nothing is fetched
at runtime.
eBird histogram data rebuilds quarterly via the Refresh eBird data GitHub
Action (needs EBIRD_API_TOKEN + a fresh EBIRD_COOKIE secret — see
frame/HANDOFF.md for the iPad-only procedure).
| Path | What |
|---|---|
frame/ |
the entire app (no build step — static ES modules) |
frame/src/ |
UI, model, and generated data modules |
frame/data/ |
per-county eBird data + taxonomy (committed, refreshed quarterly) |
frame/scripts/ |
data generators & the eBird build |
.github/workflows/ |
deploy (Cloudflare Pages), quarterly data refresh, releases, keepalive |
CLAUDE.md |
standing working rules for AI sessions on this repo |
Work lands on a claude/* branch → pushed to staging for a Cloudflare
preview → on-device acceptance on the developer's actual iPad is a hard
gate → PR merges to main, which deploys production and auto-publishes a
GitHub Release from the top of CHANGELOG.md. App version = service-worker
cache name (frame-v<n> in frame/sw.js); the two bump together.
Free · on-device · offline-first · no account · no install required · honest labels (an inferred number is always marked inferred, and every failure state explains itself and offers a way forward).
A top priority, not a feature. Colour-blind-inconsiderate design is a
fail state for this project: every visual encoding must carry a non-hue
channel (luminance step, shape, size, or text), text contrast is measured
against WCAG AA (frame/scripts/contrast-check.mjs), and changed screens are
verified in grayscale before they ship. The working contract — design rules,
verification recipe, and the live audit register — is
ACCESSIBILITY.md.