Skip to main content

AD-035: In-Context Review

Summary

Translations are reviewed on the running app, not in a file. A build-time transform stamps each extracted element with its block hash; a dev-server middleware serves and writes the local KBF tree; a framework-free browser overlay maps a click back to a block, edits its target, and writes it into the .kbf.json on disk. Terminology and QA findings from stand-off *.overlays.jsonl files are painted onto the live text with the CSS Custom Highlight API — no DOM mutation.

Context

The file format throws context away. A reviewer looking at a spreadsheet row cannot see that "Save" landed on a 40-pixel button, that the German wraps to two lines, or that the register is wrong for a destructive-action dialog. Everything that makes a translation right or wrong is a property of where it renders, and that is exactly what the extraction step discards.

In-context editing is an established idea. The open question is how a rendered pixel maps back to a source string.

Two mechanisms were considered and not taken:

  • Fiber inspection. Reading React's internal tree for debug source information is not viable: React 19 exposes no _debugSource.
  • In-band markers. Encoding the identifier into the rendered text (invisible characters, private-use code points) needs no build step, but the rendered string is then not the string the app computed: equality comparisons, measurement, and copy/paste all see the marker, and an attribute value has no text node to carry one at all. Review must be observation-free — the app under review has to behave exactly as it does in production.

What we want is an out-of-band channel the build already has an opportunity to write, and that the rendered text never sees.

Decision

Stamp the DOM at build time

Under review: true (or KAPI_REVIEW=1), the transform adds to each extracted element:

AttributeCarries
data-kapi-idthe block hash
data-kapi-locfile:line — jump-to-source
data-kapi-attrhashes of the element's translatable attributes

The identifier goes in an attribute, never in the text. The rendered string stays byte-identical to production, === still works, copy/paste is clean, and attribute blocks (which have no text node to hide a marker in) are addressable on the same footing as children. The plugin already visits every extracted element to rewrite it, so the stamp is free — no separate traversal, and no runtime identification machinery.

This is dev/staging only, and gated behind an explicit flag: it puts source paths in the DOM and mounts a middleware that writes to disk.

Head strings have no DOM node — a runtime registry

The stamp needs an element to attach to. The document head has none: a <title>, a <meta name="description">, an Open Graph card title are page metadata, not rendered content, so there is nothing to hover and nothing to outline — and these are frequently the highest-SEO-value strings on the page.

They are reached through the opt-in @neokapi/i18n-react/head hooks (useTranslatedTitle, useTranslatedDescription, useTranslatedMeta). A head string never gets a DOM text node, so the build transform cannot rewrite it the way it rewrites a t() call. Instead the hook self-hashes its source at render time, using a browser port of the build hasher over a dedicated head descriptor channel, and looks the translation up in the runtime dictionary. The extractor recognises the same hook calls and emits a matching block, so the hash the hook computes and the Block.hash the extractor writes are identical by construction — the hash-parity invariant, extended to the head. (A hash-parity test asserts the two hashers agree.)

As it applies the string, the hook registers { kind, hash, source } in a small framework-free registry the overlay reads. The overlay lists the page's head translatables in a panel and edits them through the identical PUT /{hash} write-back a body string uses — the block exists in the KBF tree, so the store keys on it with no new protocol. Nothing scrapes arbitrary head markup: only strings the pipeline translated register, and a page with none shows no panel.

The dev server serves the KBF tree

/__kapi/review (mounted by the Vite plugin, plain Node http so it ports to any dev server) over the local KBF directory:

RoutePurpose
GET /{hash}block payload — source runs, targets, notes
PUT /{hash}write a target back into the .kbf.json
GET /annotationsstand-off findings, keyed by block hash
GET /eventsSSE — updates broadcast to every open tab

The index is rebuilt whenever any .kbf.json / .overlays.jsonl mtime drifts, so an out-of-band kapi translate or kapi exec qa shows up without a restart.

Write back to the .kbf.json, not to a database

A reviewed target is written into the block's .kbf.json file as a target run. The obvious alternative — a review database with comments, states, and an approve button — was rejected.

The .kbf.json tree is already the contract between developers and translators: extract produces it, kapi translate fills it, QA reads it, compile ships it. Writing a reviewed target into it makes the review a git diff — reviewable in a PR, revertable, attributable, and consumed by the very next compile with no synchronisation step. A reviewer's fix travels the identical path as a translator's, because it is the identical artifact. It also means review needs no server, no account, and no network: git clone && vp dev.

The cost is that local review has no workflow state (assignment, approval, threaded comments). That is deliberate — those belong to the platform tier (see Related), and the same stamping and the same hashes carry the overlay there unchanged.

The identifier is content-addressed — review runs on any render mode

data-kapi-id is the block's content hashhashKey(source, descriptor) — computed identically whether the build inlines the translation (zero-runtime, SSG/SSR) or emits a runtime t() call, by the same hash-parity invariant. Nothing about the stamp depends on a runtime dictionary being present, so review is render-mode-independent: an inline/SSG build stamps the same id an OTA build does, and one manifest resolves both.

To make a statically-rendered page self-sufficient, the plugin emits a read-only translations/review.json (source, per-locale target, file:line, element) whenever review: true — gated on review, not on mode, so an inline build emits it as much as a runtime one. A hosted overlay (@neokapi/i18n-react/review/hosted) fetches that file, finds elements by data-kapi-id, and needs neither a dev server nor the app's i18n runtime: deep-link ?kapi-focus=<hash>, a whole-page index, and read-only inspection all work on a plain static deploy. With edit: true it edits through the identical PUT /{hash} protocol and repaints by patching the element's text directly — text-only, so blocks with inline codes or ICU plurals save to the store but are not repainted live — giving live in-context editing even on a page with no runtime dictionary to repaint through. Richer manifests (every locale, term/QA annotations) still come from neokapi-i18n compile --review over the whole KBF tree.

Paint findings with the CSS Custom Highlight API

Terminology and QA results are stand-off annotations (*.overlays.jsonl) anchored to run-index ranges — the framework's existing overlay model (AD-002). The browser renders them via CSS.highlights: Range objects are registered in a named Highlight and styled with ::highlight(kapi-term).

Nothing is inserted into the DOM. The classic approach — wrapping matched text in <span class="highlight"> — mutates the tree React owns, which means layout shifts, hydration mismatches, and a React re-render silently discarding the highlights. Custom Highlight paints in the text renderer, beside the DOM: React's tree is untouched, so what the reviewer sees is the app, not the app plus the instrument.

The mapping from a stand-off range to a Range is direct, because both are offsets into the same flat text the block already carries.

Consequences

  • The reviewer sees the app. Every property that only exists at render time — truncation, wrapping, tone-in-place, a term that reads wrong next to the button beside it — is visible at review time.
  • Review is a diff. No import/export, no sync step, no platform dependency for the local tier.
  • QA and terminology become visual. The checks kapi already runs stop being a list of line numbers and become marks on the words they are about — which is the only form in which a reviewer will act on them.
  • The stamp is the seam. The same data-kapi-id that lets the local overlay find a .kbf.json block lets a staging overlay find a platform block. The Tier-2 (Bowrain) surface is a different backend behind the same client contract, not a different feature.
  • Render-mode-independent. Because the id is a content hash, the same stamp and the same review.json drive review on an inline/SSG/SSR page, a runtime/OTA page, and the platform tier — one mechanism, every render mode.
  • Enable deliberately. Review is opt-in per build. Stamping puts file:line in the DOM, the dev server mounts a writer, and inline review bakes a review.json — all appropriate for dev and preview/staging deploys, but turn review off for the production build end users get.