projects / a project

library · 2026

from anna

Handwritten letters, photographed and published at a link meant for one person, with a transcription anyone can read. The highlighting half is an npm package.

Read one: annamaria.app, as a letter. My portfolio, written by hand on one sheet, photographed, and published here. (lol)

Overview

A letter arrives closed: an addressed envelope, front-on, with a stamp. Opening it turns the envelope over, lifts the flap and unfolds the sheets.

I write a letter by hand, take a photo of each page, and upload them. A vision model reads the handwriting and sends back markdown. I correct it against the photo, mark what I want read first, and publish it at a link meant for one person.

The photo is what you see. The transcription is what you can actually read, and it is the letter's content, not a caption. A picture of handwriting is invisible to a screen reader.

The highlighting half is published on its own as remark-scanned-page. The rest is an app with one user and no sign up. The code is MIT, so the way to have your own is to clone it.

The problem it solves

  • A photo of a letter is not a letter on the web. It can't be read aloud, searched or copied from. A transcription behind a "show text" button makes the readable version the optional one.
  • Highlights stored as character offsets break the first time you fix a comma, and a handwritten transcription gets corrected constantly.
  • A letter you send either gets opened or it doesn't, and you never find out which. Nor whether it landed with anyone.

Highlights inside the markdown

Inline: :mark[text worth reading first]{c=important}
 
:::theme{label="a name for the block"}
A group of paragraphs, drawn as a bracket with the name beside it.
:::

The .md file carries everything. No join, no sync, and a correction moves the highlight with the text, because the highlight is the text. The package defines no vocabulary, only the mechanism and three colour slots. The transcription is model output, so it goes through a sanitize schema that restricts href to https: and mailto: and blocks scripts, iframes and every on* handler.

Writing one happens in a three-step desk: photographs, write & mark, share. Highlights can be added straight on the rendered words: the package annotates the preview with source positions after sanitizing, so a repeated word maps to its exact place in the markdown instead of the first match.

What it proves

  • The transcription ships in the server HTML. A letter arrives in an envelope and opens into a deck of sheets, with the photograph on the back of each one. Which face shows is a CSS decision, never a mount decision, and with no JavaScript the envelope is just a header with every sheet readable below it.
  • Colour never carries meaning alone. Every tag has its own underline, solid, dotted or wavy. Contrast is measured against the highlight fill, not the page, by a test that reads the stylesheet.
  • Unpublishing takes the photo down too. The Blob store is private and every photo goes through a route that checks the letter's status. A public URL would outlive the letter.
  • A wrong link, a draft, an expired letter and someone else's get the same 404, status and body.
  • Signed in is not allowed in. An allowlist decides, and an empty one denies everyone.
  • A reply instead of a measurement. A view when the letter opens, recorded after the response, and a reaction the reader picks from a closed list of six, like replying to a message in a chat app. One row per reader per reaction, the reader being an opaque session cookie. No IP, nothing about the person, and my own visits are not counted. The desk shows opens, who reacted, the reactions, and reactions by ?from= source.
  • Cross-site writes to /api/ are refused before a route reads them, and the Permissions-Policy denies camera, microphone, location and payment.
  • Dark mode is true black. The saved mode is applied from an inline script in the <head> before first paint, so there is no white flash, and theme-color follows it.

The transcription

All pages go to the model in one call, separated by ---, and the response is split back per page. If the count disagrees, the whole text is saved to page 1: losing the split is recoverable, losing the transcription is not. Anything unreadable comes back as [?] instead of a guess, and the editor jumps from one to the next. The raw output is kept apart from the edited text.

A finding I left alone

A letter written in block capitals comes back in block capitals. That is correct: the model is transcribing. Fixing the case in the prompt would make the raw something other than a record of the page. So there is a "Convert ALL CAPS to sentence case" button that runs on the edited text only, and says every time that it can't tell which words are names.

Stack

  • Next.js 16, TypeScript, Tailwind v4, entrepta v4 in light mode and true-black dark mode
  • unified, remark-directive and rehype-sanitize, through the package
  • Postgres on Neon with Drizzle, private Vercel Blob
  • A vision model through OpenRouter, Clerk for the one door in
  • Photos resized in the browser to 2400px and 1200px, HEIC converted on the way
  • Paper sounds generated offline, with no API call at runtime

Running locally

git clone https://github.com/imnotannamaria/from-anna
cd from-anna
npm install
cp .env.example .env.local
npm run db:migrate
npm run dev

You'll need Postgres, a private Vercel Blob store, an OpenRouter key and a Clerk app. Restrict OpenRouter to providers that don't train on your data before the first call.

Tests

Vitest covers what breaks quietly: contrast of every tag against its fill, the sanitize schema, the page splitter, the admin gate, the bot filter, and the editor helpers. The release workflow installs the package tarball in an empty directory and runs it before publishing.

What the tests could not see: the Google Fonts import was dropped by the build, and the site was in Times New Roman until I opened the inspector. The fonts come from next/font now.

The first version of the end-of-letter measurement recorded a full read for a reader who never left sheet one: the coda was laid out behind the envelope, so the observer fired on arrival. Nothing errored, and the table filled with plausible rows. It was found in a real browser, against the database, not by reading the diff. That measurement is gone now. Reactions replaced it.

License

MIT, the app and the package. The two paper sounds are the exception: generated with elevenlabs.io on the free plan, so they are credited and kept out of the MIT grant.

https://from-anna.vercel.app/annamariaapp-as-a-letter

https://github.com/imnotannamaria/from-anna

https://www.npmjs.com/package/remark-scanned-page

Anna Maria

Software engineer from Brazil.

I build things end to end: UI → front (web / mobile) → back → AI → deploy, and I have been doing it for about 5 years, in startups and in big company teams.

This site is open source.

Three ways to read it: Front, Back and IA.

Abimaela, my cat, lives in the corner and reacts to what you do.

Built with Next.js, MDX and Postgres, on the entrepta design system.

places you can go from here

a game, with a board
  • Memory game: turn two cards, and a pair found is a page found.
  • The 10 fastest rounds go on the board, with the name of who played.
time:00:00moves:000
Click a card to start. The clock starts with it.

board· top 10

Reading the board...

© anna maria, 2026one line changed in the license: all rights reserved forkablefork your own