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. The kit draws and inspects. A host that edits commits through the change service (E-09): it sends a change set naming the revision it rendered, and the service applies it to the block and writes the document back through reader, skeleton and writer, whoever edited.

renderedit → writeSourcefile / storeReader+ skeletonEditorrender + inspectChange setset_content · if_matchWriter+ skeletonOutputfaithful originalContent modelBlock · Run · Edition

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. Every surface that edits content sends the same change set to the same service, so the contract worth pinning down is read → change set → write, and the kit's part in it is the read.

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/format-ops/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; a host commits​

@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 kit holds no commit path, which keeps it dependency-light UI every one of those consumers shares. A host that edits builds the commit beside the viewer on the change service. Kapi Desktop's document view does: a click on a block in the Preview puts it in focus, and Edit opens the block's source, or the translation the view shows, in an editor under the document. The editor reads the block through the service, draws each inline code as a chip and a plural a form at a time (a translation's own plural, which the read lists beside the block), and saves a set_content with the revision it read; the document is inspected again once the change lands. A block that changed since it was read is refused with the text it holds now, which the editor shows before asking to apply the edit over it (S-02).

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.

A surface commits an edit as a change set (E-09): an operation addressed to an edition of a block ({doc, block, edition}), naming in if_match the revision of that edition the surface rendered, with the new content in the placeholder form a read shows. The change service applies it with change.ApplyBlock, the one function that changes a block's content, checks the result, and commits it through the document's home, which for a project file is the file itself. A revision that moved since the read refuses the change with the edition as it stands, so no surface overwrites a change it did not see.

Change setset_contentif_matcheditApplyBlockcore/changein placeeditReader replaythe homeinject the editeditWriter+ skeletoneditOutputfaithful original

The change service replays the document through its reader, applies the change set to the blocks it names, and reconstructs byte-faithful output through the skeleton.

The round-trip is a framework mechanism independent of who edited: the document is replayed through its reader, the change is applied to 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 edited 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); an agent sends the same change set through kapi apply or its MCP tools (S-03).

What persists, what is reconstructed​

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

  • Editions are first-class records: runs plus status, origin, score and the edition each was derived from, the source under the empty edition key beside each translation (KBF schema 2.0).
  • Annotations are the block-scoped typed carrier; the block file carries blocks, their editions, 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.
  • Committing stays outside the kit, in the host, and every host commits through the one change service, which is what keeps the kit dependency-light and every edit governed and recorded the same way.
  • Faithful round-trip is a property of reader, skeleton, and writer, not of any editor: applying the change set to the read blocks and replaying the document is what reconstructs it.
  • 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. A commit is a change set through the change service.

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.