A bubble on the selection
An agent drafts the posts on this site through an MCP server. I review them on a private preview page that shows each draft in the real post layout. When we planned that page, I asked for a small bubble on highlighted text: a green tick if I like it, a question mark if I don't follow, an angry face if I don't like it. Images get the same bubble on hover, or on tap on a phone.
It works like a Messenger reaction. Select some text, and once the selection has been still for 250 ms a bubble offers โ โ ๐ and ๐ฌ. Pick one. The bubble closes, the passage takes a tint, and the emoji sits at its end as a badge. Hovering the badge lets me change the reaction or add a note, which is plain text with a row of one-tap emoji under it.
Why the anchor is a quote
The loop goes: I leave feedback, the agent edits the draft, I look again. So the text will have changed by the next time the page shows my note. An anchor stored as "paragraph 4, character 112" would point at a different sentence the moment the agent added one above it.
An anchor is the quote itself, plus up to 32 characters either side, taken from the post's text with whitespace collapsed. buildIndex walks every text node in the article and joins them, so a quote can run across a link or an <em>. Then it squeezes each run of whitespace to a single space and remembers where every character came from:
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i]!;
rawToNorm[i] = text.length;
if (/\s/.test(ch)) {
if (!inSpace) { text += " "; normToRaw.push(i); }
inSpace = true;
} else {
text += ch; normToRaw.push(i); inSpace = false;
}
}
Those two arrays are the bridge back. A match found in the collapsed string becomes a DOM Range over the real text nodes.
Scoring repeated phrases
On each render, locateQuote finds every occurrence of the quote. A phrase can appear twice, and the stored prefix and suffix decide which one I meant: each candidate scores a point per character of context that still matches, counting outward until the first difference.
for (let at = index.text.indexOf(quote); at !== -1; at = index.text.indexOf(quote, at + 1)) {
const before = index.text.slice(Math.max(0, at - CONTEXT), at);
const after = index.text.slice(at + quote.length, at + quote.length + CONTEXT);
let score = 0;
for (let i = 1; i <= Math.min(before.length, anchor.prefix.length); i += 1) {
if (before[before.length - i] === anchor.prefix[anchor.prefix.length - i]) score += 1; else break;
}
for (let i = 0; i < Math.min(after.length, anchor.suffix.length); i += 1) {
if (after[i] === anchor.suffix[i]) score += 1; else break;
}
if (score > bestScore) { best = at; bestScore = score; }
}
The quote itself must match exactly. Edits around it are fine. Change one word inside it and the search comes back empty, so the item moves to "No longer in the draft" in the feedback panel, still showing its original quote. A ๐ goes the same way once the agent rewrites its sentence, and keeps the agent's reply underneath.
Highlights without a single extra span
The preview renders the post body with the same <Prose> server component as the public page: sanitised HTML, set from a string, with no client JavaScript. Wrapping each quote in a <mark> would mean editing that markup in the browser. Then the page I review is not quite the page that ships.
The CSS Custom Highlight API marks text without touching the DOM. You register Range objects under a name, then style the name:
if ("highlights" in CSS) {
for (const key of Object.keys(ranges) as (keyof typeof ranges)[]) {
CSS.highlights.set(`pv-${key}`, new Highlight(...ranges[key]));
}
}
::highlight(pv-unclear) {
background-color: var(--hor-accent-wash);
text-decoration: underline wavy var(--hor-accent);
}
There's a catch. A highlight isn't an element, so nothing fires when you click it; the layer keeps each range's rectangles and does its own hit test. The badges are ordinary buttons laid over the text. Browsers without CSS.highlights still get the badges, only without the tint. Resolved items lose the tint and keep a badge at 55% opacity.
The build's CSS parser rejected .hor ::highlight(...), the page-scoped form the rest of the stylesheet uses, so the four rules moved to the top level. It still prints a warning for them, but the built stylesheet has all four.
Three fixes from the first test run
Scripted browser runs against a throwaway draft caught three bugs before the commit.
- Two items on one passage drew their badges in exactly the same spot. Badge positions are now grouped on an 8-pixel grid, and each extra badge in a group moves 30 pixels right.
- The image bubble stayed open after the pointer left the image, and while it was open, hovering a badge did nothing. Leaving now starts a 250 ms timer that reaching the bubble cancels, and a badge hover closes the image bubble.
- On a phone, the bubble ran off the right edge.
clampLeftkeeps it inside the article.
What the agent is allowed to close
The agent reads feedback through list_post_feedback. Each item carries its quote, and the response spells out the reactions: โ
keep this, โ reword or explain, ๐ rewrite or remove. After editing, it calls resolve_post_feedback, which checks two things:
if (item.reaction === 'love' && item.note === null) {
invalid({ code: 'conflict', field: 'feedbackId', message: 'A โ
with no note is a standing "keep this" instruction; leave it open.' });
}
const reply = input.reply.trim();
const sentences = reply.split(/(?<=[.!?])\s+/).filter(Boolean).length;
if (!reply || reply.length > 600 || sentences > 3) { /* rejected */ }
A bare โ has nothing to resolve, so it stays open and marked as standing in every list the agent reads, round after round, until the post goes live. Everything else needs a reply of one to three sentences on what changed, and I read those replies on the preview page rather than comparing drafts. Reopen sends an item back. So does editing its note or reaction.
Going live archives all of it, standing โ
s too. The rows stay in Convex with the status archived, where nothing shows them.