Go Outside
A micro-nudge app. A few small prompts a day, validated on-device, rewarded locally, visualized well.
No server. No account. No competition. No social graph. Everything lives in bundled JSON and device storage. The only network calls in the entire app are an optional weather lookup and a one-time model download - and the app works fine without either.
Part of the Earth App family alongside crust (web), sky (mobile) and cloud (backend). Recess borrows their patterns but extends none of them; it is standalone.
Each day Recess picks four nudges - plus a bonus once those are resolved - from a catalog of 120, filtered by the clock, the season, the weather, your permissions and what you have already done. Some are things to do, some are questions, some are just something to sit with. Then the day ends. There is no feed, nothing to catch up on, nothing to scroll.
Some nudges can be checked. Photograph the thing you made and an on-device CLIP model decides whether it looks like what the nudge asked for. Write about what you found out and a sentence embedder scores it against a rubric. Say out loud what you hear and Whisper transcribes it first. All of it runs on the phone; nothing is uploaded, ever. If a model is missing or fails, the app says so and lets you mark it done yourself - it never silently passes.
Resolving nudges grows a Playground: a generative scene derived entirely from your completion history. Points scale that scene and unlock new kinds of nudge. They buy nothing, because there is nothing to buy.
bun install
bun run dev # http://localhost:3001Native builds need Xcode (iOS) or Android Studio, plus ImageMagick for the icon pipeline:
brew install imagemagick
bun run sync:assets # icons + splash for both platforms
bun run build:ios # generate -> sync:version -> cap sync -> cap build| Command | What it does |
|---|---|
bun run dev |
Nuxt dev server on :3001 |
bun run generate |
Static build into .output/public (aliased dist/) |
bun run build:ios / build:android |
Full native build, including version sync |
bun run sync:assets |
@capacitor/assets + iOS light/dark/tinted variants + watchOS icon |
bun run sync:version |
package.json version into pbxproj and build.gradle |
bun run typecheck |
vue-tsc -b --noEmit |
bun run test:unit |
Vitest gate lane |
bun run test:e2e |
Playwright against the static build |
bun run test:eval |
On-device validator accuracy vs fixtures |
bun run test:eval -- --tiers |
Tier 1/2/3 accuracy-vs-cost comparison |
typecheck must keep the -b. The root tsconfig.json is a solution-style config (files: [] plus references), so a plain vue-tsc --noEmit type-checks nothing and exits 0 with real errors present.
src/
types/ nudge.ts is the contract - zod schemas + inferred types
utils/ the engine, all pure and all unit-tested
data.ts locale-aware catalog loading + region-overlay merge
filters.ts filter evaluation (unknown values pass, never block)
recommend.ts the deterministic daily picker
validate.ts validation dispatch; fails CLOSED
rubric.ts embedding cosine scoring, ported from cloud
ml.ts transformers.js pipelines, lazily imported
tiers.ts the device-tier model table (plain data, deliberately)
playground.ts scene derivation from the ledger
streak.ts forgiving grace-day streak math
stores/ pinia: nudges, progress, models
composables/ platform + feature layer
components/ nudge/ validation/ playground/ week/ onboarding/ ui/
pages/ onboarding + tabs/{today,playground,week,settings}
data/ the nudge catalog (see below)
The engine is deliberately separable from the UI: every pure function takes now as an explicit parameter rather than reading the clock, which is why most of the 400-odd unit tests need no fake timers.
recommendDaily seeds a PRNG from hash(dayKey + locale), so the same day always produces the same set. The deck therefore survives a relaunch without persisting anything, and a test can assert an exact selection. Weighted sampling rather than a sort keeps it from becoming a fixed rotation.
Signals: category recency, type variety, a points spread across low/mid/high bands, duration versus remaining daylight, tag distance from yesterday, skip decay, cooldown, unlock gating, and a down-weight (never an exclusion) when a nudge's model pack is not installed.
Capacitor's docs say localStorage "must be considered transient" because the OS may reclaim WebView storage under disk pressure. That is a no-guarantee caveat rather than routine eviction - but Recess has no server to fall back on, so:
Preferences- everything the user owns: ledger, points, streak, bests, settings, onboarding, device tier, pack registry.Filesystem- model weights and captured media, plus the durable mirror behind Cache Storage.localStorage- disposable per-session UI state only.
Preferences is configured with the App Group group.com.earthapp.recess, which is also how the Watch and Widget read state. That shared UserDefaults suite is the entire native bridge; only the Live Activity needs a custom plugin.
Nudges are bundled JSON at src/data/<locale>/<category>/<type>.json.
- 9 categories:
peopleadventurehomelearncookingnatureerrandsexerciseart - 7 types:
taskquestionthinkchoosecreatenoticecount - Locales:
enandesare complete trees;en-GBandes-MXare sparse overlays merged over their base language by id.
- Open (or create)
src/data/en/<category>/<type>.json. It is a JSON array. - Start typing -
.vscode/schemas/gives autocomplete and validation for that exact type. - Run
bun run test:unit.tests/unit/utils/data.spec.tswalks every file in the tree and enforces the invariants below.
The id is the primary key of the completion ledger, the cooldown tracker and the overlay merge. Never rename one - editing a title is safe, editing an id erases history.
- ids are globally unique once composed as
<category>.<type>.<slug> - rubric weights sum to exactly 1.0
- every CLIP
labelis English and phrased"a photo of ...", in every locale (CLIP is English-only, and these are internal prompts the user never sees) - every photo nudge has
negative_labels- without them the score has nothing to compete against - thresholds sit in 0.5-0.9; unrelated content floors near 0.5 because of the
(cos+1)/2normalization, so anything lower accepts everything - every
leads_toandcompletedfilter points at a nudge that exists esmirrors everyenid, and a region overlay only overrides ids present in its base
audionudges must ask the user to speak.validateAudiotranscribes first and returnsmissedon an empty transcript, so "record the ambient sound" is unpassable by construction.model_packandpermissionfilters are AND.is: [...]requires every listed token. Audio scoring needsis: ["audio", "text"]- the transcriber and the embedder.
Enum filters use is / is_not. Numeric filters use greater_than, greater_than_or_eq, less_than, less_than_or_eq, equals, between, AND-combined within one value.
An unknown or indeterminate filter passes. Weather with no snapshot, daylight with no location, a cooldown on a never-completed nudge - all pass. Filters block only on a definite mismatch, so a user who denies every permission still gets a full day.
Weather comes from Open-Meteo (no key, no signup), cached 6 hours. The 27 conditions map the WMO 4677 subset Open-Meteo actually reports, plus derived groups (any_rain, severe, hot, windy, ...). There is no tornado - Open-Meteo does not emit that code, and inventing one would be a fabricated fact.
Four opt-in packs: vision (CLIP), text (sentence embeddings), audio (Whisper), writing (a small LLM for feedback lines and weekly reflections). Nothing downloads without an explicit tap.
On first launch a capability benchmark measures WebGPU availability, cores, reported memory and a real timed matmul, then assigns a device tier. Reported specs lie on throttled and low-battery devices, so the timed workload gets a veto - a slow device cannot reach tier 3 regardless of what it claims. Settings shows the detected tier and lets you override it.
Weights land in Cache Storage (transformers.js owns that) and are mirrored into Filesystem, because Cache carries the same no-guarantee caveat as localStorage and re-downloading hundreds of megabytes on a low-disk device would be a bad failure. Once a pack is installed, env.allowRemoteModels = false and the app is genuinely offline.
Pack sizes are read from the Hugging Face file listing and from measured on-disk usage, never hardcoded. If a size cannot be determined the UI says "Size unavailable" rather than inventing a plausible number.
Tier 3's models are provisional. bun run test:eval -- --tiers measures accuracy against latency and download cost on identical fixtures; if tier 3 does not earn its weight it collapses onto tier 2 by editing src/utils/tiers.ts and nothing else.
crust and sky's client moderation fails open because their server is the real gate. Recess has no server, so a silently-passing validator would mean the app lying. Every failure path - missing model, timeout, inference error, offline - returns unavailable, which the UI turns into an explicit "We couldn't check this one. Mark it done yourself?"
Grounded in the behavior-design-evidence skill. Tangible rewards for an already-enjoyed act undermine intrinsic motivation (Deci/Koestner/Ryan 1999, all tangible d=-0.34; expected tangible d=-0.36); verbal feedback enhances it (d=+0.33 in adults, d=0.11 ns in children). The worst contingency is rewarding mere participation (d=-0.40).
So points buy nothing. No shop, no cosmetics, no currency, no levels, no ranks, no titles. Crossing a threshold reveals a capability ("You Can Now...") and grows the Playground. Every progress cue is self-referential - "Your Longest Yet", never a number beside someone else's.
The streak carries one grace day per rolling seven, and two misses pause it rather than resetting to zero. There is no "you lost your streak" notification, ever.
Notifications are three calm daily digests (default 08:30 / 13:00 / 18:30) - batched digests beat both real-time notification and silence (Fitz 2019). They are goal-shaped and suppressed once the day is done.
Both libraries are installed and they are not interchangeable. <UApp> is never mounted - the root is <IonApp> - and every Nuxt UI overlay (UModal, UPopover, useToast, ...) teleports into that missing host. Ionic owns structure and every control; Nuxt UI is UIcon, UBadge, UAvatar, UAlert, USeparator, UForm, UTable, UProgress, UTooltip.
Tailwind utilities inside an <Ion*> or on a bare <button> need a ! suffix, or Ionic's CSS silently wins.
Full conventions are in CLAUDE.md.
Three lanes, different budgets.
- Unit (
tests/unit/) - deterministic, local, free, a few seconds.vi.hoistedplusvi.mockabove the subject import. Component tests live intests/unit/components/and usemountSuspendedfrom@nuxt/test-utils/runtime; the one caveat is that Ionic custom elements render as stubs under happy-dom, so assert on what the component itself decides and leave Ionic's own rendering to e2e. - E2E (
tests/e2e/) - Playwright against the static build.native-mock.tsfakes the whole Capacitor bridge via an init script, which is the only way these journeys are drivable in a browser given there is no backend to intercept.*.mobile.spec.tsruns on Pixel 7;webkitis opt-in and is the closest engine to the shipped WKWebView. - Eval (
tests/eval/) - validator precision and recall against labeled fixtures, with a threshold sweep that recommends the value to ship. Never hand-tune a threshold without re-running it.
Do not bump nuxt past 4.4.x. nuxt@4.5 requires @unhead/vue@^3 while @nuxt/ui@4.10 and @nuxtjs/ionic still require ^2. The ranges are disjoint, bun hoists v2, and .nuxt/unhead-options.mjs fails with [MISSING_EXPORT] legacyPlugins. The same trap is documented in crust and sky. vitest and @vitest/coverage-v8 are pinned to exact 4.1.9 because 4.1.10 regressed the uncovered-file TS parser.
See LICENSE.
- Framework: Nuxt by the Nuxt team
- UI: Ionic for structure, @nuxt/ui for decoration
- On-device models: transformers.js by Hugging Face
- Icons: Iconify
- Developed by: The Earth App
{ "id": "first_bird", // short slug, unique per category+type "icon": "mdi:bird", // a real Iconify name "color": "@green", // @alias, #hex, rgb() or rgba() "points": 10, // <=10 low, 11-19 mid, >=20 high "duration_minutes": 5, // optional; a real recommender signal "tags": ["birds"], // variety axis inside a category "prompt": "Find a bird and watch it until it does something.", "validation_type": "photo", "validation_data": { "labels": ["a photo of a bird on a branch"], "negative_labels": ["a photo of an empty sky", "a screenshot of a screen"], "threshold": 0.6, "require_fresh_exif": true } }