Gå til hovedinnhold

S-06: The visual editor data model

Summary

The visual editor is a render-and-inspect surface over the content model (F-02), shipped as a kit in @neokapi/ui-primitives under the ./preview subpath. A document's Part stream is projected in Go to a hierarchical, JSON-serializable ContentTree (core/editor), then normalized on the TypeScript side to a RenderDoc whose kind drives a format-shaped renderer. Inline runs are styled through the vocabulary; stand-off overlays render as inline marks whose accent is a function of the overlay type and the span's own properties. Every projection of a run sequence to text is declared per run kind, so a placeholder or a plural can never vanish from a view. Editing is deliberately kept out of the kit: the canonical way to commit a translation is the model's own SetTargetRuns, and the byte-faithful round-trip is a property of reader + skeleton + writer, independent of who edited.

renderedit → writeSourcefile / storeReader+ skeletonEditorrender + inspectEditSetTargetRunsWriter+ skeletonOutputfaithful originalContent modelBlock · Run · Target

The top row projects model to pixels; the bottom row commits an edit and reconstructs faithful output.

Context

The content model, the format system and its byte-faithful skeleton, the tool system, and the interchange package family each have their own decision record. The editor spans two of them and belongs to neither, for two reasons.

It spans Go and TypeScript. The model is projected to a ContentTree in core/editor, then normalized and rendered in packages/ui. The hand-off, the ContentTree and its TypeScript mirror, is the seam where the two halves meet, and a seam nobody wrote down is a seam that drifts.

The back path is the least obvious part. The framework supplies a target-edit primitive and a faithful round-trip, but ships no production translation-editing application. So the contract worth pinning down is model → edit → write, not any particular interface's commit flow.

Decision

The editor renders the content model

The bridge between the engine and any preview surface is the ContentTree (core/editor/anatomy.go): a hierarchical, JSON-serializable view of a document's Part stream. BuildContentTree walks the stream with a container stack, attaching each part to the innermost open container and producing nodes of kind layer, group, block, data, or media.

A block node preserves what the model holds: the run sequences for source and each target, per-target metadata, the stand-off overlay views carrying the text each span covers, and segment spans derived by run-index range from the segmentation overlay (M-02).

ContentTree is distinct from the editor's other projection, BlockIndex, which flattens a block's source to plain strings for reconstruction. The preview kit consumes the run-preserving ContentTree, so the editor renders exactly what the model holds (inline placeholders, paired codes, plurals) rather than a lossy string view.

Render path

Sourcefile / storechanReaderDataFormatReaderchanContentTreecore/editorPart stream → treechanRenderDocrenderDoc.tsSTRUCTURE_RULESchanViewDocumentViewer

A Part stream becomes a hierarchical tree, normalized to a format-shaped document whose kind selects the renderer.

treeToRenderDoc normalizes a ContentTree into a RenderDoc through a data-driven STRUCTURE_RULES table. It collects target locales across all blocks, then tries layer-shape detectors in declaration order, first match winning (presentation slides, spreadsheet worksheets, a word-processing document body, a paged layer pattern), falling back to a format-family classification and finally to a generic section extraction. The result carries a kind of slides, sheet, doc, pages, list, or sections, and every block projects to a render line carrying its id, text, inline codes, per-locale targets, role, overlays, and annotations.

FormatPreview dispatches on that kind to a kind-specific renderer. Leaf text goes through a shared component that applies the active transition, resolves overlay marks, and, when a before document is supplied, word-level diff highlighting.

DocumentViewer composes the full surface: a header carrying the filename, a file-type badge, the byte size, and a download button; a source-to-target toggle shown only when target locales exist; and a tab strip. Preview, Blocks, and Stats are always present. Structure appears when the tree carries structural roles, Layout when it carries page geometry (E-08), Media when it carries a temporal or raster media layer (M-03), and Raw when the host actually holds the bytes. A host can append its own tabs; the lab uses them for output-format pills. Downloading is the single header button rather than a duplicate tab, because on the desktop the local file is already the source of truth.

Projection is declared per run kind

Turning a run sequence into text is the step every surface performs and the one easiest to get wrong. The lossy form is a loop that keeps text runs and skips the rest, which reads as "concatenate the text" and behaves as "delete every placeholder, paired code and plural". Nothing fails; the content is gone.

So a projection is declared, never looped. A RunSpec (packages/kapi-format/src/run-projection.ts) answers for every kind in RUN_KINDS, and its type is a mapped type over that list, so a spec that omits a kind does not compile. Each kind is rendered, expanded, dropped with a stated reason, or declared unsupported and reported, in which case the spec's required fallback is drawn in its place. A kind added to the model breaks every projection until each has said what it does with it. scripts/check-run-projection.sh keeps a hand-written loop from returning; it runs in make lint and in CI.

The kit's render line therefore carries a block's inline codes beside its text, so a placeholder is drawn as the same chip wherever it appears, and a plural block reads its other branch behind a chip that says so rather than rendering as an empty line. The chip names the variable a placeholder stands for, because the JSX vocabulary is one the default registry loads.

Run, vocabulary, rendering

Inline runs are styled through the vocabulary registry. The vocabulary packs are canonical as a Go embed in core/model/vocabularies; packages/ui/src/vocabularies carries a byte-identical mirror, and scripts/check-vocab-packs.mjs holds the two equal in make lint. Resolving a run's type yields display information: a category, a label, HTML and display templates, a chip label, a colour, an equivalence text, and editing constraints. An unknown type is not an error: the registry synthesizes an entry from a default fallback, interpolating the type name into the templates, deriving a short chip label, and giving it a neutral accent and permissive constraints. The editor uses those fields for styled chips, tooltips, and the deletable, cloneable, and reorderable affordances.

This mirrors the model contract exactly: the vocabulary is descriptive. It drives display and editing affordances and is never consulted by writers, which replay each run's own data verbatim (F-02).

Overlays become marks, keyed on type and span

Stand-off overlays become colour-coded, tooltipped marks. Three functions do the work, and each embodies a decision.

The accent key is the effective category. A voice-vocabulary violation rides on the generic qa overlay type, so an accent chosen on type alone would paint it as an ordinary finding. The resolver therefore reads the span's own category: a qa span categorised as voice vocabulary resolves to the voice accent, and everything else resolves on its type. The style table gives terminology a violet "Vocabulary" accent, voice a pink "Voice", other findings an amber, entities a sky blue, segmentation and alignment their own muted accents, and redaction an entry whose label and tooltip are used while the renderer paints a censor bar instead of a background. Unknown keys fall back to a neutral "Annotation" accent with a title-cased label, so a new overlay type renders sensibly before anyone teaches the table about it.

Spans are located by their text. Overlays anchor to run-index ranges, but the renderer works over the concatenated literal text, so matching by the engine-extracted span text is robust across that projection. A per-overlay search cursor advances past each match, so repeated identical span texts within one overlay map to successive occurrences rather than all to the first. Spans whose text cannot be located (a span covering only inline markup, for instance) are dropped rather than mispositioned. One overlay type is excluded outright: a content-memory leverage marker is a line-level fact, not a span highlight, and the renderer styles the whole line from it.

Overlaps flatten innermost-wins. For each character position the narrowest covering span owns it, and contiguous positions under the same owner emit one non-overlapping segment, so a term inside an entity still shows as the term.

BlockInspector is the structural counterpart to the styled preview: a collapsible per-block view rendering the source run sequence, each variant's row (variant key, status, score, origin), each overlay's rows (type, side, layer, and per-span id, run range, text, and properties), annotation rows, the properties grid, and flag badges.

The kit renders and inspects; it does not commit

@neokapi/ui-primitives is the single source of truth for the kit, exported under ./preview: the viewer and format preview, the file browser, the block inspector, the structure, layout, and content-tree views, run sequences and code views, the multimodal viewers for timed media and raster overlays, and the supporting utilities (the render-doc normalizer, the overlay style and span resolvers, run-range mapping, role styling, geometry flattening, and the vocabulary registry).

ConsumerHow it uses the kit
Kapi Desktopfile inspection inside a project tab (S-02)
Kapi Labwraps the viewer to inspect engine output; the explorers use the tree and browser views
The playground and docs sitethe same viewer over WASM-produced output

The boundary is deliberate. The framework ships no production translation-editing surface, and an application that needs one builds its commit surface on the model's own primitive and its own persistence. Keeping the kit free of an editing and commit dependency is exactly what lets every one of those consumers share it as dependency-light UI.

Edit path and round-trip

The editor-side BlockIndex carries a flattened, string-valued view; its UpdateTarget method mutates that in-memory projection and is a test helper, not a commit path.

The canonical way to commit a target edit is on the content model itself: model.Block.SetTargetRuns(locale, runs) sets the variant's runs in place, with the plain-text siblings SetTargetText and SetText for the string path (E-03). How a host transports an edit to the model and persists the result (the project block store, interchange files, a database) is the host's concern.

Edittarget runseditSetTargetRunsmodel.Blockin placeeditReader replaysourceinject targetseditWriter+ skeletoneditOutputfaithful original

The round-trip replays the source through its reader, injects the committed targets, and reconstructs byte-faithful output through the skeleton. Persistence is the host's concern.

The round-trip is a framework mechanism independent of who edited: the source is replayed through its reader, the committed targets are injected into the emitted block parts, and the writer reconstructs the document by pairing the reader's skeleton emitter with the writer's skeleton consumer, interleaving literal skeleton fragments with the target runs rather than re-serializing a parse tree (E-02). The equivalent bilingual round-trip for a translator hand-off is the extract and merge workflow (M-01); the equivalent for an assistant-proposed edit is kapi apply (S-03).

What persists, what is reconstructed

Within the content model and its interchange family (M-06):

  • Targets are first-class records: runs plus status, origin, and score.
  • Annotations are the block-scoped typed carrier; the block file carries blocks, targets, and properties, and the JSON-Lines sidecar carries annotation overlays with their anchor kinds.
  • The skeleton is the binary skeleton store (E-02).

Overlays are reconstructed on demand rather than serialized as positional structure: segmentation is recomputed from the runs, and term, entity, and check overlays are re-attached by the tools that produce them within a session. Because overlay spans anchor to run ranges, a source rewrite shifts or drops them through the framework's overlay remapping; targets and annotations are unaffected.

Consequences

  • The editor has one documented contract shared by every consumer, so a new render kind, overlay accent, or inspector row is a contained change against a known seam.
  • Rendering is a pure projection of the content model: the editor cannot diverge from what the engine holds, because it consumes the run-preserving tree rather than a separate model.
  • A projection that forgets a run kind fails to compile, and a hand-written loop fails lint, so content cannot silently disappear from a view.
  • Resolving the accent from type and span keeps one overlay type able to carry several kinds of finding without the renderer flattening them into one colour.
  • Locating spans by text rather than by range makes the marks robust across the run-to-text projection, at the price of silently dropping a span whose text does not appear, which is the right trade when the alternative is a mark on the wrong words.
  • Editing and committing stay outside the kit and outside the framework, which is what keeps the kit dependency-light and shareable.
  • Faithful round-trip is a property of reader, skeleton, and writer, not of any editor: setting a target run sequence and replaying the source is what reconstructs the document.
  • Overlays are ephemeral in the live preview: a durable interpretation must be stored as an annotation, or re-derived by re-running the tool that produced it.
  • BlockIndex.UpdateTarget is a test helper. Relying on it as a commit path would be a mistake; SetTargetRuns is the canonical operation.

Open questions

  • Overlay persistence. The framework has no persistence layer for positional overlays; the JSON-Lines sidecar is defined but not yet read or written by the preview kit. Whether term, entity, and check overlays should persist rather than always be re-derived is unsettled.
  • BlockIndex lifecycle on edit. Whether a frontend holds its own index and sends delta edits, or re-derives it after each commit, is not pinned down.
  • Display-hint and content-reference scope. Whether these are populated by every reader and persisted by a given host store is format- and application-dependent.
  • Segment span ids across edits. Whether overlay span ids stay stable when source text changes, or shift with rebasing.