</ripple>
Civic reporting PWA that classifies the photo on the phone, before it uploads
15 ms
on-device inference, p50 (p95 22 ms) against a 3,000 ms budget — WebGPU on Apple Silicon, a lower bound rather than a mid-range phone
5,434,517 bytes
classifier binary actually served from the live URL — and byte-identical from cache with the network cut
0
accounts, logins or personal details required to file a report — identity is a random UUID in localStorage
68
mobile Lighthouse Performance, below this project's own 90 gate — published rather than replaced with the flattering desktop number (96)
SPECIFICATIONS
| ROLE | SOLO BUILD |
|---|---|
| YEAR | 2026 |
| TYPE | PWA |
| STATUS | IN DEVELOPMENT · FRONT END LIVE |
| STACK | react 19 · typescript · vite · pwa · litert.js · tflite · webgpu · mapbox-gl +7 more |
| LINKS | [live ↗][github ↗] |
| AVAILABILITY | ripple-chi-bice.vercel.app |
“Councils have the budget to fix broken infrastructure, and citizens can see what is broken.”
Ripple is a civic infrastructure reporting PWA: photograph a broken streetlight, and the classifier runs on your phone before anything uploads — no account, no sign-up, no personal details. The front end is live and installable, and the browser really does fetch a 5.4 MB int8 TFLite model and a LiteRT WASM runtime and run inference on the device, well inside a three-second budget. There is no backend behind it: the Supabase project it was built against was deleted and deliberately not re-provisioned, so nothing that reads or writes data works. Every number on this page cites a committed artifact — including the one that fails the project's own gate.
Councils have the budget to fix broken infrastructure, and citizens can see what is broken. The bridge between the two is a council web form that takes about fifteen minutes and wants a full name, address and phone number — so most problems are simply never reported, and without volume data councils have nothing to prioritise against.
Rebuild that bridge as a progressive web app with no accounts in it. You open a map of what has been reported nearby, photograph the problem, and submit; a random UUID in localStorage is the only identifier the system holds. Classification runs on the phone under LiteRT.js — WebGPU where available, WASM where not — so the photo is transmitted only after Submit and only as evidence, never as something a server has to interpret. Reports made with no signal queue in IndexedDB and flush on reconnect, and council attribution is a point-in-polygon test against cached boundaries, decided on the device before anything is sent. The front end of all of that is live; the data layer behind it is not deployed.
role & context
Solo build: product, design system, front end, PostgreSQL schema and row-level security, the Deno edge layer and the on-device inference pipeline. Also the auditor — the pass that found the false completion claims was run against my own earlier work, and its findings are the spine of this page.
You see the same pothole every day on your commute. Reporting it means a council web form that takes about fifteen minutes and wants your full name, address and phone number — so you don't, and neither does anyone else, and the council never learns the road is a priority. Ripple is that bridge rebuilt: open the map, photograph the problem, submit. A random UUID in localStorage is the only identifier the system holds.
The part that makes it defensible is that classification happens on the phone. An int8 .tflite model runs under LiteRT.js — WebGPU where available, WASM and XNNPACK where not — and returns a category and a confidence before anything is uploaded. The photo is transmitted only after Submit, and only as evidence, never as something a server has to interpret. Warm inference measures 15 ms at p50 against a 3,000 ms budget, but the latency is the second reason; the first is that a civic app that loses the trust argument does not get installed.
The interface treats the model as fallible on purpose. Above 85% confidence the result snaps in. Between 60 and 84% it asks "does this look right?". Below 60% it offers two options and emphasises neither. If the model is missing or the runtime fails, it drops to a manual picker, and every override is recorded as {ai_category, ai_confidence, final_category} — which is what will make accuracy measurable later, rather than asserted now. It has to be, because the model that ships is a generic ImageNet backbone: the runtime, the cache path and the latency are real, and the civic fine-tune is not built.
The real story of this project is the audit. In March the repository said twenty sprints were complete, on the criterion that TypeScript and ESLint passed — for a codebase with zero tests. In August I measured it against its own checkmarks instead, and found seven false subtask claims, no test files at all against a written "no test, no merge" rule, and a Supabase project that returned NXDOMAIN. Every "applied to production" line described a database that no longer existed. Everything on this page after that point exists because something that had been marked done turned out not to be.
React 19, TypeScript and Vite, built as an offline-first PWA and served as one bundle: no React Native version, no Swift version, no Capacitor wrapper. Mapbox GL renders the map with clustering and a keyboard-navigable parallel list, because a canvas has no accessibility tree. State is Zustand; the offline queue is IndexedDB via idb, mounted at the app root so it flushes from anywhere rather than only from the report screen.
Inference is @litertjs/core with the WASM runtime served from our own origin rather than a CDN, and the model fetched from a versioned URL so swapping in a fine-tune is a one-line change. Preprocessing is the Canvas API — centre crop, never squash — which is what lets TensorFlow.js stay out of the dependency tree entirely rather than being swapped for something else.
The data layer is PostgreSQL with row-level security as the security boundary, Deno edge functions for submission and status changes with Zod validation and server-side rate limiting, a generated tsvector column with a GIN index for search, and logical replication for live updates. Twenty-seven migrations apply cleanly to a throwaway PostgreSQL 17.11 and are then attacked as the anon role — that harness is the only reason the data layer can be spoken about at all, because the hosted project it was written against was deleted.
Council attribution runs client-side: Turf point-in-polygon against cached boundary polygons, with overlapping areas resolved by nearest centroid rather than by unspecified row order, because attribution that depends on which row the database returns first is a bug waiting for a busy day.
Inference on the device, verifiably
The live build serves a 5,434,517-byte int8 TFLite model and a 9.4 MB LiteRT WASM runtime from its own origin, and runs them in the browser — 15 ms at p50 against a 3,000 ms budget. What is claimed is where the inference happens, not that it is right.
The model is treated as fallible, in the interface
At 85% and above the result card snaps in. Between 60 and 84% it asks "does this look right?". Below 60% it fans out two options and emphasises neither. If the model is missing or the runtime fails, the flow drops silently to a manual picker — a broken classifier never blocks a report.
No account, and nothing to leak
A random UUID in localStorage is the whole identity model; private browsing degrades to an ephemeral token rather than breaking. There is no sign-up, no email requirement, and no way to link a report to a person.
One column of a public table, made private
The reporter token was readable off the public map and replayable as a header. RLS could not fix it — it is row-level and filters no columns — and the intuitive column REVOKE is a no-op against a table GRANT. The fix drops the table grant and re-grants only the public columns.
Works with no signal
Reports queue in IndexedDB and flush from anywhere in the app on reconnect, not just from the report screen. Boundary polygons are cached, so the council a report belongs to is resolved offline too.
Council attribution on the device
A Turf point-in-polygon test against cached boundaries, with overlapping areas resolved by nearest centroid rather than by whatever row order the database happened to return. The boundaries themselves are still placeholder bounding boxes, so attribution near an edge is approximate.
DECISION 01
Classification runs before the upload, not after it. The photo never leaves the device to be understood — only after Submit, and only as evidence. That is a privacy architecture first and a latency win second, and the bill arrives as bytes: the LiteRT runtime plus the model puts first load near 7 MB against a stated "<5 s on 4G", which is exactly why the mobile Lighthouse Performance score is 68 and fails this project's own gate. The number is published rather than swapped for the desktop 96.
DECISION 02
LiteRT.js, with TensorFlow.js removed rather than replaced. Google's own quickstart routes inference through @litertjs/tfjs-interop, which drags TF.js back in purely to build tensors — and TF.js is frozen at 4.22.0, October 2024. @litertjs/core's own type definitions show CompiledModel.run(Tensor[]) and Tensor.fromTypedArray() are native, so preprocessing with Canvas removes the dependency outright. @tensorflow/tfjs is not in package.json.
DECISION 03
Search shipped on Postgres, not Elasticsearch. The plan wanted an index, a sync edge function, a nightly re-index and a proxy: roughly $16 a month and a second datastore for a product with zero users. A generated tsvector column cannot drift from its source, so the sync pipeline and the re-index cease to exist rather than being maintained. Deferred rather than rejected — useSearch is backend-agnostic, so the swap costs one file.
DECISION 04
Category identity moved from colour to a monospace glyph. The design system permits one accent, so colour cannot encode ten categories — and it never did: streetlight and signage already shared a hex, so the ten-colour scheme was only ever nine-distinguishable. Shape survives greyscale, colour blindness and low outdoor contrast, which is the condition the app is used in.
DECISION 05
Community consensus flags "fixed"; it does not set it. Three confirmation photos set community_suggests_fixed and stop there. Consensus is evidence, not authority, and the status belongs to the council — a crowd that can close tickets is a crowd that can be organised into closing them.
DECISION 06
The security fix is a GRANT problem, not an RLS problem. The reporter token could be harvested off the public map and replayed as a header to read a stranger's notification email and delete their upvote. There is no row-level answer, because the rows must stay public — the map and the comment thread are the product. Postgres has exactly one mechanism for one private column of a public table, and the intuitive spelling of it does nothing: a column REVOKE cannot subtract from a table GRANT, raises no error, and reports success. The migration drops the table grant and re-grants the columns that stay public.
In March the repository reported twenty sprints complete. The completion criterion offered was "TypeScript and ESLint pass with zero errors" — which, for a codebase with no test files at all, measures compilation rather than correctness. An August audit re-measured the repo against its own checkmarks instead of trusting them: seven subtask claims were individually false, there was not one test file against a written "no test, no merge" rule, and the Supabase project the entire backend was written against returned NXDOMAIN from two independent public resolvers. Because Supabase keeps paused projects resolving, that meant deleted, not paused — so every "applied to production" line in the plan described a database that no longer existed.
The more useful output was not the bug list but a question to ask of any green check: what would this have to look like to fail? Four of this project's own gates had no answer. The typecheck gate ran `tsc --noEmit` against the root tsconfig, a solution file with `"files": []`, so it checked zero files and exited 0. The PWA check used the regex `/navigateFallback|offline\.html/`, which accepts either answer and could therefore never detect the wrong one. The RLS tests declared the attacker's token as a literal fixture, proving the attack failed without ever testing whether the credential could be obtained. And a column-level `REVOKE` reports success whether or not it changed anything. All four were green, and not one of them could have failed.
The security repairs were worse than the bookkeeping. Two RLS policies named `_own` were written `USING (true)`, so any anonymous caller could delete anyone's upvote and every stored notification email was world-readable. Fixing those was recorded as proven by attack. It was not: running the whole chain rather than its second half found `reports.reporter_token` still world-readable, because RLS is row-level and filters no columns. Harvest a token off the public map, replay it as the `x-reporter-token` header, read a stranger's notification email, delete their upvote — four steps, all four succeeded, against a migration comment that asserted in writing that tokens were not handed out. The fix is the sharpest detail in the repo: `REVOKE SELECT (reporter_token) ON reports FROM anon` is a silent no-op while a table-level `GRANT SELECT` exists, because table and column privileges live in separate ACLs and the table-level check short-circuits the per-column one. Migration 025 drops the table grant and re-grants only the columns that stay public.
Then the ones a user would have hit. GPS failure was an unrecoverable dead end: at the timeout the hook cleared its loading flag with latitude still null, so the location line rendered as blank space and Submit stayed permanently disabled — no error, no way forward. All eight of the Edge Function's validation messages were unreachable, because supabase-js does not parse non-2xx bodies, so someone over the note limit was told "Edge Function returned a non-2xx status code". The service worker served the offline page to online users — `navigateFallback` pointed at offline.html and a workbox `NavigationRoute` matches every navigation — which broke every deep link, the exact surface shared reports exist to provide, and hid because in-app link clicks issue no navigation request. The offline queue only flushed while the user happened to be sitting on /report. Rate limiting was localStorage theatre, defeated by one devtools click. And three colour tokens sat below the WCAG AA contrast floor in an app whose stated purpose includes reporting accessibility hazards, where the first attempt at a fix changed nothing visible, because the palette is defined in three places and only one was edited.
Every figure was produced by running its command against the frozen tree, not carried forward from a document. Inference was benchmarked with the real LiteRT WASM runtime against a real int8 .tflite in a real browser: 15 ms p50, 22 ms p95, against a 3,000 ms budget — and recorded as a lower bound on Apple Silicon with WebGPU, explicitly not a mid-range-Android figure.
The database layer was proved by execution rather than review. A throwaway local PostgreSQL 17.11 applies all 27 migrations and is then attacked as the `anon` role: 102 assertions, 0 failures. It earned its keep on the first run by catching migration 017 calling `calculate_priority(NEW.id)` from a BEFORE UPDATE trigger, where the table still holds the old row — so the function re-read the status it was in the middle of replacing and the status penalty never applied. That is the exact bug the migration exists to fix, reintroduced one line below the comment describing it, and it would have been invisible in production in the direction that keeps resolved reports ranked urgent on a council dashboard. Realtime was read straight off a pgoutput replication slot, the same protocol Supabase consumes.
Offline was verified by cutting the network: the model resolves from Cache Storage byte-identical at 5,434,517 bytes and every route still renders. Lighthouse was run on both presets, which is how the 92 previously on record turned out to be a desktop number quoted without its form factor — on the mobile preset a 375px-first PWA is actually judged by, Performance is 68.
DECISION 01
Classify on the phone, before the upload
because The photo never leaves the device to be understood — only after Submit, and only as evidence. That is a privacy architecture first and a latency win second, and it is the whole trust argument: a civic app that loses it does not get installed. The cost is paid in bytes, and it is paid honestly — the runtime and model put first load near 7 MB, which is why the mobile Lighthouse number is what it is.
DECISION 02
LiteRT.js, with no TensorFlow.js anywhere
because Google's own LiteRT quickstart routes inference through @litertjs/tfjs-interop, which drags TF.js back in purely to build tensors — and TF.js is frozen at 4.22.0 from October 2024. Reading @litertjs/core's own type definitions showed CompiledModel.run(Tensor[]) and Tensor.fromTypedArray() are native, so preprocessing with the Canvas API removes the dependency outright instead of swapping it. @tensorflow/tfjs is not in package.json.
DECISION 03
A broken classifier never blocks a report
because If the model is missing, the runtime fails, or a browser advertises WebGPU and then fails to compile, the flow drops to the manual category picker instead of erroring. The classifier is an accelerant on a path that has to work without it — and every override is stored as {ai_category, ai_confidence, final_category}, which is what makes accuracy measurable later rather than asserted now.
DECISION 04
Postgres full-text search instead of Elasticsearch
because The plan specified an index, a sync Edge Function, a nightly re-index and a proxy — roughly $16 a month and a second datastore for a product with zero users. A generated tsvector column cannot drift from its source, so the sync pipeline and the re-index cease to exist rather than being maintained. Deferred, not rejected: useSearch is deliberately backend-agnostic, so the swap costs one file.
DECISION 05
Category identity in a monospace glyph, not colour
because The design system permits one accent, so colour cannot encode ten categories — and it never did: streetlight and signage already shared a hex, making the "10-colour" scheme 9-distinguishable. Shape survives greyscale, colour blindness and low outdoor contrast, which is the condition this app is actually used in: one hand, bright sun, standing over a pothole.
DECISION 06
Community consensus flags "fixed"; it does not set it
because Three confirmation photos set community_suggests_fixed and stop there. Consensus is evidence, not authority — the status belongs to the council. A crowd that can close tickets is a crowd that can be organised into closing them, which is the failure mode that would end a council pilot on week one.
The front end is deployed and the PWA, map, offline shell and on-device classifier all work at the live URL — but there is no backend behind it. The Supabase project it was built against no longer resolves and has deliberately not been re-provisioned, so nothing that reads or writes data functions: reports do not load or submit, and Storage uploads, Auth, Edge Function deployment and Realtime delivery are all unproven, because they are managed services with no local stand-in. The database layer itself is verified by execution against a local PostgreSQL 17; what is unverified is specifically what Supabase-the-service adds on top. The classifier pipeline is complete and benchmarked but ships a generic ImageNet EfficientNet-Lite0 backbone rather than a civic fine-tune, so no accuracy claim is made — inference runs on the device, and that is the only thing being claimed about it. First load is near 7 MB against a stated "<5 s on 4G" budget, which is why mobile Lighthouse Performance is 68 and fails this project's own gate. Council boundaries are placeholder bounding boxes that overlap rather than ABS polygons, so attribution near a boundary is approximate; and the last taps — Add to Home Screen on a physical iPhone, confirming the OS camera opens, frame rate on a mid-range Android — need a handset and have not happened.
A green check is only a gate if you can say what would make it fail. Four of mine could not fail: a typecheck that checked zero files, a PWA regex that accepted either answer, an attack test handed the credential it was meant to prove unobtainable, and a REVOKE that cannot subtract from a GRANT.
Row-level security is row-level. Nothing in RLS filters a column, so a table that must stay public with one column that must not is a GRANT problem — and the obvious spelling of the fix changes nothing and reports success.
Deploying finds bugs no local gate can. The build shipped no classifier from a clean checkout because the model is gitignored, and seven of eight routes 404'd for want of a vercel.json. Both hid because vite preview does history fallback itself, in-app links issue no navigation request, and the service worker serves the shell anyway — so only cold loads and shared links broke. Only strangers.
Publishing your own failing number is cheaper than defending a flattering one. Mobile Performance is 68 against a self-imposed 90, because roughly 7 MB of runtime and model is what on-device inference actually costs on first load. The previous 92 on record was a desktop score quoted without its form factor.
What remains is owner-gated rather than unfinished. Re-provisioning Supabase and applying the migrations is the single highest-impact item, and it is what turns every schema-layer claim here into a deployed one. After that: roughly 500 images per category across ten categories to train the civic classifier that makes an accuracy claim possible; ABS council boundary polygons to replace the overlapping placeholders; twelve legal placeholders on the Terms page that were deliberately left uninvented and are gated by a test; and a physical handset for the taps no automation can make. None of it is blocked on cost — the only genuinely paid line item in the plan was Elasticsearch, and Postgres full-text replaced it.
react 19 · typescript · vite · pwa · litert.js · tflite · webgpu · mapbox-gl · supabase · postgresql · deno · turf.js · zustand · idb · tailwind
IN DEVELOPMENT · FRONT END LIVE · ripple-chi-bice.vercel.app