Acquaintances is a contacts app organized around how you know people. The whole product is one screen: a bubble graph of everyone you know, and a row of chips generated from your own data. Tap a venue and the people from it converge to the center while everyone else drifts out and dims.
I rarely forget the context of a person. "The guy I met at Bings who does sound design" is right there. It is the name that is gone, and every contacts app indexes on the name.
So this one indexes on the context. Seven days in it is live, it holds my own people, and it has already lost some of them once.
Try it now
Live at: acquaintances.app
It asks you for nothing. No account, no email, nothing to install. You land on a demo cast with the whole product working: tap a chip to filter, tap a bubble to focus it, search, pan, pinch. Signing in is a button in the corner you can ignore.

Nobody in that picture is real, and keeping it that way turned out to be the hardest standing requirement in the project. The rest of this is how it got built, in the order it happened.
The rules live in one place (Aug 5)
Day one produced an architecture decision record before it produced any code. An ADR
is a short markdown file: one per decision, numbered, kept in docs/adr/. It
states what was chosen, what else was on the table, and what the choice costs. A
call made in one afternoon is still legible a month later, when the reasoning is
otherwise gone.
ADR 0001 opens with four hard constraints from the brief:
- 390×844 baseline, one-handed, safe-area aware
- 60fps layout animation at ~300 nodes on a mid-range phone
- every field optional except name
- no accounts, no backend, all data on device
The second is the one the rest of the repo is downstream of. The graph
visualization is the product, and it runs on an imperative <canvas> with a
custom hit-test, pinch and pan gestures, and a render loop that gets 6ms a frame to
do its work. React goes nowhere near that loop. Neither does anything that decides
what the picture means.
So the repo is three layers, and the target written into the ADR is 70% of the logic
in core/.
ui/ ──────► core/ ◄────── platform/
(React, (pure TS, (IndexedDB, photos,
canvas, no DOM) haptics, importers)
gestures)
| Layer | Holds | May reach for |
|---|---|---|
core/ | data model, layout engine, facet ranking, filtering, validation, the storage interface | nothing at all, not even a dependency |
platform/ | IndexedDB, photo downscaling, haptics, clock, id generation, importers | the browser, behind an interface core/ declared |
ui/ | React, canvas, gestures. Owns no rules. | both of the above |
The dependency arrows only ever point one way, and that is enforced by lint rather
than by intent. eslint.config.js bans window, document, indexedDB,
navigator, fetch and localStorage inside core/, and bans core/ from
importing ui/, platform/, React, or any backend SDK. The ADR is blunt about why:
a boundary maintained by good intentions is a boundary that is gone in six months.
The practical shape of it is that core/ never calls Date.now() or
crypto.randomUUID(). It takes a Clock and an IdGenerator as arguments. That
looks like ceremony for about five days, and then it does not: every layout is
reproducible from its inputs, every test runs without a fake timer, and "it came out
different that time" stops being a thing that can happen to you.
One day to a working graph (Aug 5)
24 commits. The first one in the repository is not the data model and not the app.
It is Ignore private data before anything else lands. This is a database of third
parties who never consented to being in it, and the gitignore went in before there
was anything to ignore.
A person has exactly one required field: name. Everything else is an
{ personId, typeId, value } row: where you met, where they live, what they do.

That is load-bearing rather than decorative. The filter chips are generated from the
frequency of attribute values across your own graph, so adding a new kind of field
has to be possible without a migration or a UI rewrite. Adding one is a single entry
in ATTRIBUTE_TYPES; the add form, the profile sheet, the chip ranker and the
proximity model all read from that list. Star sign is derived from birthday on read
and never stored, so correcting the birthday corrects the sign.
It paid off inside the first session. Importing a real dataset needed eight attribute types the model had never heard of, and it cost one array literal.
Chips are ranked, not configured. rankFacets scores every candidate value by how
many people share it, with diminishing returns so one huge value cannot crowd out
every venue, plus how recently you entered it and how much that kind of attribute
means. Values held by only one person are dropped, because tapping them is just
opening that person the long way round.

Once a filter is on, the remaining chips re-rank against the current slice, so the row narrows with you. Shared attributes bend the layout but are never materialized as edges: the edge set stays exactly what you asserted, because turning shared values into stored relationships would claim friendships that do not exist. Rare shared values count for far more than common ones. Sharing a six-person bar is signal. Sharing a city that 194 of your 282 people live in is not, and is ignored outright.
One force drives all three views.
| View | Target ring |
|---|---|
| Resting | everyone weakly held near the origin |
| Filtered | matches at radius 0, everyone else banished to a far ring and dimmed |
| Focused | the person pinned at the origin, everyone else at a radius set by connection strength |

The brief specified d3-force. The simulation is hand-written over Float32Arrays
instead, for two reasons that point the same way. d3 jitters coincident nodes with
Math.random, which core/ is not allowed to call, so any determinism test written
against it is testing luck. And the hand-written version allocates nothing per tick,
which is a property a test asserts rather than a claim in a comment. At 300 nodes a
tick costs ~0.3ms against a 6ms budget, and the render loop parks itself when the
simulation settles, so a graph at rest costs no battery.
The accepted cost is that I own the integrator, including its bugs. The mitigation is
that the force model is deliberately d3-shaped: link springs, many-body charge,
collision, radial targets, alpha decay. Putting d3 back is mechanical rather than
a rewrite.
Accounts, without giving up local-first (Aug 6)
28 commits, the heaviest day in the repo. ADR 0001 had said no accounts, so adding them started by writing down what that gives up rather than quietly amending it.
The shape is the same trick as everywhere else. core/auth/session.ts declares an
AuthPort and owns nothing that implements it. platform/supabase/auth.ts is the
browser's answer, and LocalOnlyAuth is the answer when no backend is configured,
which is why nothing outside boot() has to branch on whether accounts exist at all.
Leave the environment file absent and the app runs exactly as it always did: signed
out, on device, no network. That is what the public deploy does.
Sync went in the same evening: one comparable stamp on every record, tombstones and a dirty set, and the merge written as a pure function with no network in it. Row-level security was proven with two real sessions rather than asserted, and one rule carries most of the weight: a pulled record is never marked for push, because two devices bouncing one row between them is the failure that does not stop on its own.
The genuinely hard question was what happens to the graph already on the device.
seedSourcesays what put the data there,userAuthoredsays whether the user has since made it theirs, and both are needed: an untouched demo graph is worth nothing, an edited one is worth everything, and the private seed is real people either way.
Upload is a recommendation, never an action. The engine asks through a callback and
does nothing on a no, and deliberately leaves lastSyncedUserId unset so the next
sign-in asks afresh instead of remembering a refusal as a decision.
The bug that ate people (Aug 7)
That design is careful. It shipped broken anyway.
The private seed loads with seedSource of existing on every boot after the first,
because the source describes this boot, not the data's origin. Deciding by source
alone marked exactly the graph the feature exists to protect as discardable. Sign in,
nothing uploads, and lastSyncedUserId records the account as synced so it is never
offered again.
Silent and permanent. It was found by its intended owner asking where their people had gone.
The rule now discards only what is provably synthetic: the demo cast by id, or a
fixture the user never touched. Everything else is somebody's people and gets the
confirm-then-upload path. The lesson is not "test more". It is that seedSource
answered a question one word away from the question being asked, and a field that
answers almost the right question reads as correct in every review.
Going live at acquaintances.app
The deploy is where the privacy posture gets tested, and both ways to get it wrong are silent.
A plain vercel deploy filters uploads through .gitignore, and public/data/ is
gitignored, so it ships the synthetic fixture while looking like a success. The only
sign is guard: no private data staged in the build log, which reads like a pass.
And a preview deploy is only safe at all because Vercel Authentication gates it,
which is a property nobody checks after the first time.
So neither is left to discipline. npm run preview:real stages the seed, builds
locally, deploys --prebuilt, asserts the seed is actually in the output before
uploading, verifies that an anonymous request gets bounced, and deletes
.vercel/output afterwards. That last step matters more than it looks: a
--prebuilt deploy skips the build entirely, so a stale output directory holding real
data would be published by a --prod flag without anything running to stop it.
| Where | What it serves |
|---|---|
npm run dev | data/seed.json if present, else the demo fixture |
| preview deploy | whatever was staged at build time, behind auth, noindex |
| production | the synthetic fixture, always. The build refuses otherwise |
Underneath it, scripts/guard-private.mjs runs first in npm run build and fails
outright when public/data/ exists and VERCEL_ENV is production. The unsafe
command differs from the safe one by a single flag, so it is blocked rather than
discouraged.
That leaves the public site needing a graph that means something while being nobody real. Signed-out visitors pick a cast.

Where it is now
Seven days old. 373 tests across 26 suites: 17 of those suites in core/, 9 in
platform/, 1 in ui/, which is the layer split showing up as a number. A
row-level security suite is held out of that run because it needs two real Supabase
sessions rather than mocks; npm run rls is one command instead of the four
environment variables it started as. npm run check is typecheck, lint, format and
tests together, and it is what runs before anything ships.
It still does not work offline. Your people live in IndexedDB on the device, but there is no service worker caching the app shell, so with no network the page cannot load at all. The data is there and unreachable. Installing to the home screen gets you standalone display, not offline. The README said otherwise for about a day before that got corrected.
What I learned
A boundary maintained by good intentions is already gone. The layer rule is an ESLint config, not a convention, and it has failed the build several times over seven days. Every one of those would otherwise have been a small reasonable exception, and the rules would now be spread across three layers instead of sitting in one.
Determinism is worth its ceremony. Passing a Clock and an IdGenerator into
core/ looked like overhead for five days, and it is the reason the layout engine
can be tested at all: same input, same picture, every run. It is also why d3-force
was rejected: a library that calls Math.random() inside its integrator cannot be
held to that, and a determinism test written against it passes by luck.
The dangerous deploy is the one that looks like it worked. Both privacy failures here print a success line. That is the specific shape worth hunting for: not the command that errors, but the command that quietly does the wrong thing and logs a pass.
A field that answers almost the right question reads as correct in every review.
seedSource was not a typo or a race. It was a name that described one thing and got
used for another, and every reader of that code, me included, filled in the meaning
they expected. Reviews catch wrong code. They do not catch code that is right about a
different question.
Write down what you assumed, so being wrong is cheap. ADR 0001 said no accounts, no backend, all data on device. Thirty hours later that was false. Because the assumption was written down in a specific form, reversing it meant opening the file and recording what it cost, which took an afternoon. Vague optimism cannot be falsified, which is what makes it expensive.
What's next
- A service worker, so "offline-first" stops being aspirational
- Tests for
ui/, which is one suite deep and is where every gesture bug will come from - Barnes-Hut, if the graph ever passes ~1000 people: repulsion is O(n²) with a distance cutoff today, which measures fine at this size
Thanks for reading. More soon.