Fruitback

Architecture, and the decisions under it

Written for somebody changing Fruitback, or deciding whether to. It is the design half of the old README: the page a reader lands on answers what is this, and everything that answers why is it shaped like that is here (FRU-26).

The per-ticket histories — the measurements, the first versions that failed, the reviews that caught them — are one level down, in decisions/.

Why this shape

Alternatives we looked at and dropped:

So: build only the missing piece — the capture and restitution layer — on top of react-grab (MIT), and let the issue tracker absorb everything else. The one thing it cannot do is redraw a pin on the page; that part we reconstruct from the anchor we stored.

Architecture

The diagram shows the default path, FRUITBACK_STORE=linear. Since FRU-33 the third column is whichever connector is configured — sqlite writes a row in a file the worker owns, github an issue in a repository (FRU-32), and the shape of the exchange does not change. This page was written when Linear was the only answer, and it is scoped here rather than rewritten: the reasoning below is still why the Linear connector looks the way it does.

widget (client site)            worker (proxy)              the store (linear by default)
──────────────────              ──────────────              ──────
react-grab picker      ──POST──▶ create issue      ──────▶  issue + labels
comment popover                  (server-side token)        description = seed
pin overlay            ◀──GET─── query by label+URL ◀─────  status, comments

No dashboard and no user accounts: the tracker you already run is both. The sentence that stood here until FRU-26 said “no database, no session store” as well, and it had simply outlived itself — FRUITBACK_STORE=sqlite keeps the seeds in a file of the worker’s own since FRU-31, and FRUITBACK_SESSION_PATH keeps the extension’s sessions in another since FRU-42. Neither is a database of users, which is what the claim was ever about.

The seed

A seed is one piece of feedback planted on an element. Where it is stored is the connector’s business: linear puts it as a JSON block inside the issue description, under a human-readable summary, github puts the same block in the issue body, and sqlite puts it in a column. The two decisions below were made for the Linear connector, and they are what the markdown codec exists for. The GitHub connector uses the same codec, and finds a page’s seeds by label instead of by description contains: see GitHub Issues, and the stages it cannot say in decisions/worker.md.

Two decisions worth knowing:

Why the description and not a Linear custom field — it is portable (no workspace admin setup, survives an export) and Linear can filter on it server-side with description: { contains: <canonical url> }, which is how “the seeds of this page” is fetched without walking every issue. The cost is that a human can corrupt the block, so the parser is deliberately tolerant: it accepts any fenced block, with or without a language tag, backticks or tildes, CRLF, even an unterminated fence, and finds ours by its kind field.

Why the anchor is redundant — a selector breaks the moment the site is redeployed. Every seed therefore carries several independent ways to find the element again (selector, test id, text excerpt, domPath, and bounds as a share of the document). When none of them resolve, the pin becomes an orphan — listed aside rather than dropped on the wrong element. That degradation is what separates a demo from a tool people keep using.

Everything the widget draws lives in one Shadow root, mounted at the document origin. That is what makes “no style conflicts” true in both directions on a site whose CSS nobody has read — and the selection engine underneath it is react-grab/primitives, which hit-tests through shadow roots and iframes and reads the component and source file straight off the React fiber.

Coming back, the pin has to find its element again. The claims are tried in order — selector, test id, text, structural path, position — and the answer carries how it was found: the first three identify an element, the last two only locate a spot. A pin placed by position is drawn dashed and says so, because a neighbour that slid into a vacated slot has the same tag, the same text and the same box, and a pin that looks certain is believed.

Picking the selector is the part that decides whether any of this survives a redeploy: a test id or an author-written id is kept, a useId :r7: and a CSS-modules class are refused, and an element that repeats is anchored under the nearest ancestor that is identifiable rather than pathed from <html>.

See packages/shared/src/seed.ts, packages/shared/src/markdown-description.ts and packages/widget/src/selector.ts.

Layout

packages/shared    the seed contract: schema, markdown codec, round-trip   ✅
apps/worker        Node service in Docker: write + read path to the store  ✅
packages/widget    capture + overlay + Shadow DOM host + popover          ✅
apps/playground    hostile demo page + dev loop, on a fake Linear          ✅  dev only

Commands

pnpm install
pnpm dev          # playground on :5177 + worker on :8788, no Linear key needed
pnpm test         # node --test, across packages via Nx
pnpm e2e          # playwright, starts both servers itself
pnpm typecheck
pnpm lint         # oxlint
pnpm format:fix   # oxfmt

pnpm --filter @fruitback/shared test:watch

Tests run on Node’s own runner (node:test + node:assert/strict) against the TypeScript sources — no test framework, no transpiler, no loader in the dependency tree. Same reason relative imports carry their .ts extension: Node’s resolver wants it, and it buys node --test and node --watch for free.

On top of that sits a small Playwright suite, for the two things a DOM emulator cannot vouch for and that this product rests on: a real selector engine and real layout. pnpm dev opens the same page by hand — a deliberately hostile fake client site, with a Redéployer button that rehashes classes and shuffles the markup so re-anchoring can be watched rather than argued about. Both run against an in-memory Linear, so neither needs an API key nor touches a workspace.