Skip to content

Source Model — The segment editor — Design Spec (#TBD)

Milestone: source-model Manual: TBD — an “Editing the layout of a page” section: selecting, drawing, reshaping, merging and splitting segments on the image; setting their order, direction and type; drawing links; working with the Apple Pencil. Facts for the maintainer to write it from (no user-manual page yet): docs/contributor_manual/manual-facts/2026-10-05.md, section 3.

Design-led (Testing Constitution). The creative director owns this intent; tests enforce it; code makes them pass. Status: DRAFT. A slice of the source model: read source-model.md first. Evidence in source-survey.md. Every behaviour below is tagged [GAP] with its issue; everything is design unless stated under “What exists today”.

Intent

Everything the model can hold can be seen and edited on the page. The editor lives in the Source view (the pane the app has called the Preview; the maintainer has decided on the new name, and the rename is not this spec’s to carry through the app), on the image. Shapes are drawn in the Preview (Source view); readings are typed in the Reader; the Inspector shows the selection and may run the same audited verbs as the Preview, through the one code path, never a second implementation (reorder, Join/Combine, set language or hand, attribute; ruled 2026-09-27, build-notes-inspector.md; restated 2026-10-04, #5115). That is the split, and it is what keeps three surfaces three. It is native SwiftUI (ruled), so one editor serves the Mac, the iPad and the iPhone, feels like a Mac app, and takes the Apple Pencil.

The three surfaces stay three. The Source view shows the image and edits segments. The Reader shows readings. The Inspector shows the facts about the selection, at its level, and offers verbs on it. Selecting a segment in any of them selects it in the others.

What exists today (read on disk 2026-09-19; to be re-read before tagging)

  • Views/Preview/ImageViewer/OCRGeometryOverlay.swift draws all boxes, display only.
  • Views/Preview/ImageViewer/Regions/RegionInteractionLayer.swift sits above it and can select (click, shift-click, band), move a region, draw a new box and name it, and rate lines; delete is in ZoomableImagePreviewMac+Regions.swift. No reshape, no polygon, no merge or split, no order, no links.
  • Other things are drawn over a page too: annotations, a PDF’s own marks, and the image-editing overlay. ui/reader-overlay-frame-identity.md (frame.*) owns when any of them may draw.
  • Boxes are addressed by their position in a list, because they have no ids.
  • RegionInteractionLayer.swift is compiled for the Mac only (#if os(macOS)) and takes its clicks from the Mac’s pointer. There is no segment editing on the iPad or iPhone today. One editor for all three means this gate goes and the editor has one input seam that pointer, touch and Pencil all feed. Otherwise a second editor gets built.
  • Crops are rectangles only (media/region_crops.py).

The design

The Source view, with a segment focus, beside anything

The Source view shows a source. With a segment focus it becomes the segment editor: the tools below appear, and the image is the working surface. Because it is a pane like any other, it can sit beside a Reader (type the reading of the selected line), beside an Inspector (the facts of the selected segment), beside the Library, or beside a second Source view on another pass, another image of the same page, or another source altogether.

A text-first segment editor: direction given 2026-09-20, design below, not built. The earlier request for a Segments pane was answered with a clearer one: edit a page’s segments by editing its text. The comparison offered was the kind of audio editor where you cut the recording by cutting its transcript. You work as in a word processor, the segments change as you type, and a Source view beside it shows the page with all its boxes. Asked where it lives, the answer was a new kind of pane, and in the same breath that it could well live in the Reader. So this is a direction to design. Both ways are written out, with a recommendation; the choice is the maintainer’s.

Option 1, recommended: the Reader. For a source whose segments have readings, the Reader’s text IS those readings, in the page’s named reading order, and it is editable. Not a mode to switch on: it is what the Reader shows for such a source (nodes, not modes). The Source view beside it is linked to it, as panes already link. When no Source view is visible, the Reader shows each line’s picture above its text, so the ink is never out of sight; when one is, it does not, and nothing has to be toggled. - Keeps three surfaces three: the Source view is the page, the Reader is the words, the Inspector is the facts. Words are already typed in the Reader. - Keeps one navigator. Stepping through a source’s lines is moving the caret; reordering is cut and paste; a list of segments as rows stays a Library listing (source.editor.library-lists-segments); moving a segment to another pass stays a Source view verb. Nothing the Segments pane was asked for is left without a home. - No new pane plumbing: no sixth entry in the kind switcher, the workspaces, the saved pane lists and the per-pane state, which is where duplicate code paths have come from before.

Option 2: a sixth kind of pane. A pane made for this one job: line pictures and text in a fixed two-column layout, its own toolbar. What the modes-to-panes ruling would have to give up: that the Library is always the navigator (this pane would be a second way to move through a source’s contents); that there is no browser kind of pane; and, in practice, that the Source view, the Reader and the Inspector are the three surfaces, because words would now be typed in two places with two sets of editing rules to keep the same. It buys a layout tuned for transcription. Option 1 gets the same layout from the Reader’s line pictures.

Whichever home it has, the rules below hold. Every edit is one of the segment, reading and reading-order actions that already exist or are already specified; the text surface adds no action and no second path. Where an edit touches both a shape and words (a split, a join), it is ONE action whose existing parameters carry both, never two.

Which text it shows. One level: the lines of the page’s working pass, in its named reading order, grouped by region; a region that reads in another direction is its own block. Words and characters are not units of their own here: they are positions inside a line’s reading. A page with 20,000 segments is a page with a few hundred lines and many words, and the text still shows the lines. A page with no line level shows its lowest level that has readings, in order. Only the blocks near what is on screen are laid out, and reads stay bounded by kind and area. It is part of the editor’s speed trial (slice 12).

Typing. Typing in a line corrects the reading of the segment under the caret. A correction is a new reading, never an overwrite (source.reading.corrections-are-new): its maker is the person, set by the engine; it records what it was corrected from; the machine’s reading stays. Which reading then counts follows the project’s rule.

Return inside a line splits that line’s segment at the caret. The reading divides at the caret. Where the cut falls on the page, in order of preference: 1. Between the word before and the word after the caret, when the line has word segments or the reading carries positions for its characters: the cut crosses the baseline at the middle of the gap, at right angles to the baseline there. 2. Otherwise by proportion: the caret’s share of the reading’s length, measured along the baseline in the line’s own direction, and the cut is marked estimated. The Source view shows an estimated cut differently, with a handle, and dragging it is an ordinary reshape. 3. A caret inside a word is allowed: the same two rules, within the word. Return is never refused for want of word boxes; it is honest that the cut is a guess. The first part keeps the line’s id; the second is new; both go into the reading order where the line was.

Backspace at the start of a line joins it to the line before it in the reading order (the line before in the order, not the line above on the page). The earlier line is kept and keeps its id; the shape becomes both shapes; the readings join, with the separator the script calls for (a space, or nothing). Refused, with the reason, when the two lines are in different regions or passes: move the line first.

Deleting words deletes words, never ink. Removing text is a correction of the reading: the person is saying the word is not there. The earlier reading keeps it. If that word has a segment of its own, the segment stays, is shown in the Source view as having no reading in this reading, and is not deleted. Emptying a whole line leaves the line’s segment in place, marked as having no reading. Deleting a SEGMENT is a command of its own, said out loud (Delete Segment), and is the undoable delete the Source view already has.

Cut and paste of whole lines edits the named reading order, and nothing else: the lines’ shapes and places on the page do not change. Pasted text that is not lines just cut from this source is typing; line breaks in it become spaces, so a paste can never set off a run of estimated splits.

One selection, both ways. The caret’s line is the selected segment in the Source view, and the word under it when there are word segments; selecting boxes in the Source view selects their text. One shared selection, the one the rest of the app already uses, so a command acts on what is visibly selected.

A run of keystrokes is one action. Typing in one line gathers into one edit, committed when the caret leaves the line, when a structural key is pressed (Return, a joining Backspace, cut, paste), when the pane loses focus, on Save, or after two seconds without a keystroke. One committed edit is one new reading, one audit record and one undo step. While an edit is still gathering, Undo is the text field’s own; once committed, Undo goes through the record like any other action. A split, a join, a cut or a paste is always its own action, at once. By the ruling of 2026-09-20 the audit record’s chained part holds ids, version numbers and a keyed fingerprint; the typed words go in the content part.

Someone else got there first. A committed edit names the version it was made against. If the line or its reading has changed since, it is refused, and the typed words are not thrown away: the line shows both and asks, keep mine, take theirs, or compare. Out of reach of the engine the text is read-only and says why (ruled 2026-09-20); an edit still gathering when the connection drops is kept and committed, with its version check, when it returns.

Every direction of writing. Each block is laid out in its own direction from the cascade: left to right, right to left, top to bottom. The caret moves in reading order; “the start of a line” is its start in reading order; a cut runs along the baseline in the line’s own direction, from the right for right-to-left, downward for vertical. A page with two scripts in two directions is two blocks. Where the platform cannot lay a direction out properly, the block is shown horizontally with a plain label saying so; it is never silently reordered.

Before readings are on segments there is nothing to show. This surface is the readings of segments in a reading order. It depends on slice 8 (readings on segments), slice 10 (named reading orders, for cut and paste), the speed trial (12) and the one store, selection and input seam of the editor (13). It is placed as slice 13b, after 13; typing, splitting and joining could come before cut and paste if 10 is late.

The earlier Segments pane record (kept for the history; superseded by the ruling of 2026-09-27 above)

On 2026-09-19 the maintainer asked for a surface of its own for getting to a source’s segments: to see them with their pictures and readings, step through them, reorder them, move them between regions or passes, and edit them, with a reading beside. An earlier ruling (ui/modes-to-panes.md) says the Library is always the navigator and that there is no browser kind of pane; and the Library’s word-level view work (#4728) covers some of the same ground. Both cannot stand as written. The reviewers’ recommendation is that this be a view of the Library (rows can be segments; a strip or grid is a Library view mode) beside the Source view, not a sixth kind of pane. That is the maintainer’s to rule; it is in the morning file. Nothing is built for it until then. What holds whichever way it goes: selecting a segment selects it everywhere; every change is the same audited action the Source view makes; it draws on the one store.

The Segments pane (approved 2026-09-27; first slice being built)

The maintainer approved a Segments pane on 2026-09-27 (source-model.md, “Ruled 2026-09-27”), and it belongs with slice 13. It is a pane kind that lists the segments of the page or source in scope, beside the Preview, never inside it. The Preview stays the page; the Reader stays the words; the Inspector stays the facts. The Segments pane is the list: many segments at once, which none of the three is for.

  • What it shows. The segments as a list (a row each: its kind, its reading, where it is in the order), a strip, or a grid of their pictures with their readings. The first slice is the list; strip and grid follow with segment pictures (### Segment pictures).
  • Where it looks. Like every pane it follows the window’s current selection unless its scope is pinned: the page the Library selected, or a source’s pages. At its top, the page’s top level (its regions, in the page’s reading order); a row can be opened to its children (a region’s lines, a line’s words), with a path back up, as the Inspector’s path head works.
  • Selection follows the focused-pane rule. Picking rows makes them the selection of the focused Source view on that page. It is the same selection the Order list and the Reader’s caret write (InspectorPath.select, #5155), so the boxes light up and the Inspector follows. One selection, never a copy.
  • Reordering here is Q5’s third place (the Reader, the Inspector’s Order list, and this pane): by drag-and-drop and by ⌥⌘↑ / ⌥⌘↓ / ⌥⌘⇞ / ⌥⌘⇟. It uses the SAME store and verbs as the Order list (ReadingOrderStore, ReadingOrderMove), with ⌘Z. One implementation, never a third.
  • Gathering. It is where a gathered set is shown when it is not one page’s: “everything in hand B”, every instance of a declared sign (source.sign.gather-instances), marks of one character gathered to compare (source.letterform.compare). These come after the first slice.
  • Every change is an existing audited action. The pane defines none of its own.

One overlay, one editor

The display overlay and the region layer become one thing that draws segments and edits them, under the same frame rule as the annotation, PDF-mark and image-editing overlays (it joins that one gated family; it is not a fifth overlay with rules of its own). There is not a second renderer anywhere: the Reader’s highlights, thumbnails with boxes, and exports to SVG and PDF all come from the same segment records and the same drawing rules.

Every edit is an action

The editor owns no data. Each edit is one audited, reversible action sent to the engine, which answers with the changed segments; the editor updates only those. Undo and redo are the system’s (⌘Z), and undo what the action layer did. An agent over MCP and the command line can make every edit the editor can, through the same actions.

What you can do

  • See: what is shown follows from what you are doing, not from a panel of switches. The working pass is shown. Finer levels appear as you zoom in. Selecting a segment shows its links and its place in the order. There are two switches only: show the reading order and show the links. Comparing passes is done by opening a second pass beside the first, the same way two sources are compared.
  • Select: click; shift-click; band; “select the same kind”; step through a reading order with the arrow keys; go up to the parent or down to the children.
  • Draw: a box, a polygon, a point, a line, a baseline. On the iPad, with the Pencil.
  • Reshape: drag a corner or a side; add a point (double-click an edge or a baseline); remove a point; move the whole shape; nudge with the arrow keys.
  • Propose: click on something and the engine proposes its shape, which you then adjust.
  • Cut and join: a scissors stroke across one or many lines splits them (across columns in one go); join merges the selected segments; group lines into a region; ungroup.
  • Set: kind, pass, campaign, direction (and “reverse this line”), language, script, hand, furniture or text, table row and column.
  • Order: see the order as numbers on the page and as a list; drag in the list, or click segments in turn on the page, to reorder; choose which named order you are editing.
  • Link: drag from one segment to another and pick the link’s type; see and delete links.
  • Match: say that a segment in a new pass is the same as one in an old pass.
  • Read while you edit: readings are typed in the Reader, not the Source view. With a line selected, the Reader shows that line’s picture above its reading, and Return moves the selection to the next line in the order; the Source view follows. Line-by-line transcription is keyboard-only if you want it to be, and the three surfaces stay three.
  • Mark: note, highlight, check and tag the selection.
  • Georeference: drop control points and give them coordinates.

Every command is in the menu bar with a shortcut, in the context menu of a segment, and reachable by keyboard alone.

Apple Pencil

The Pencil draws polygons, baselines and scissors strokes, and taps to select. Separately, a researcher can trace a sign or a stroke with the Pencil: the trace is kept with its points, timing, pressure and tilt, as the stroke level of the ladder. That is how the ductus of a letter (the order and direction of its strokes) can be recorded.

It must stay smooth

A dense page can have twenty thousand word and character shapes on a very large image. The editor must zoom and pan smoothly, find the shape under the pointer at once, and drag a point without lag, on the oldest supported iPhone as well as a Mac. The survey’s advice: tile the image for deep zoom; keep shapes in a spatial index for hit-testing; draw only what is visible at the current zoom (characters appear when you are close enough to see them). SwiftUI’s simple canvas has no per-shape hit-testing or accessibility of its own, so both are built. Before the editor is built, a small trial settles the drawing approach by measuring: frames during zoom and pan at 5,000 and 20,000 shapes; time to hit-test and to drag one point; peak memory on a very large scan; and one edit going through the engine and system undo in under a tenth of a second.

The plan for slices 13 and 13b: the existing Preview, and wireframes first

Ruled 2026-09-27. The drawing editor (13) and editing a page by its text (13b) are not a new surface. They build on and upgrade the existing Preview / Source view: its selector toolbar, the markup row (select, wordSelect, drawRegion, line, highlight, note, star, check), the Edit Segments mode (#5114), the What-to-show menu, and the Inspector’s region verbs. The goal is getting the Preview good, not adding a pane beside it.

Wireframes come before any slice-13 code. The order is:

  1. Inventory what the Preview has today: the toolbar and selector, every markup-row tool, the Edit Segments mode, every switch in the What-to-show menu, and the Inspector’s region verbs.
  2. For each source.editor.* and source.textedit.* behaviour, say where it would live in that existing Preview, and what gets merged, moved or removed. Whether the What-to-show menu can come down to the ruled two switches (source.editor.two-switches) is decided here.
  3. The inventory and proposed layouts go to the manager, who turns them into wireframes the maintainer clicks through. The maintainer rules before anything is built.

Steps 1 and 2 are written in build-notes-preview-inventory.md: today’s controls, each source.editor.* and source.textedit.* behaviour placed in the existing Preview, and six questions for the maintainer.

Rulings on the wireframes (2026-09-27), the six questions of build-notes-preview-inventory.md:

  • Q1, Draw Region: merged into the one Shape tool. In the mode it draws a segment; outside it, a marquee (a run scope), as today.
  • Q2, display switches: kept, rethought as LAYERS. The image on or off, and the overlays on or off. Overlays without the image is a case the maintainer wants. Each layer has good defaults tied to the workspace. This overrides the inventory’s proposal to remove Show Regions and Show Word Bounding Boxes, and source.editor.two-switches is read as layer switches with workspace defaults, not a hard count of two.
  • Q3, the Edit Segments switch: in the pane HEAD beside the pencil, AND still in What to show.
  • Q4: Combine is renamed JOIN everywhere: the page, the Inspector and the menu.
  • Q5, reordering: in all three places (the Reader, the Inspector, the Segments pane), by drag-and-drop and by keyboard shortcuts, with ONE behaviour. One implementation with the same verbs, never three.
  • Q6, marks: a mark applies to the SELECTION, or attaches to whatever is drawn over. Check can check a whole paragraph (a region), star works like check, and highlight draws over segments. The target is what Apple Preview supports for PDF annotations. So PDFKit’s annotation types are read before marks are designed, and marks stay exportable as real PDF annotations. Applied 2026-09-27 by the manager from that standing ruling, as defaults the maintainer can overrule (build-notes-marks-pdfkit.md, its four questions): a note exports as Preview’s Note (PDF Text, the icon); star and check are named Stamps; no Square, Circle or Ink tools until asked for; and the PDF import stops folding other marks into highlight.

Ruling, the built-in three-column workspace (2026-09-27). It opens with its layers split across its panes: left, a Preview showing the image with the overlays OFF; middle, a Preview showing the overlays with the image OFF; right, the Reader, as the text editor. The other built-in workspaces keep the image on. Built as PaneConfig layer values on those two Preview leaves (previewImage, and the overlay layer), pinned by a PaneListTests test of all three panes.

Which pass the image draws (#5146, applied 2026-09-27 from the programme’s rules). When a page has several passes, the Source view draws the first that has boxes in this order: the page’s working pass as the engine reports it (PassRead.working: the pass a person CHOSE, #5156, or else the ladder’s own answer; taken from the engine since #5443 so the image and the text cannot pick by two copies of the rule, and so an outside edit from a synced folder, which the app cannot tell from an import, never jumps it) → hand-curated (a person’s pass, or any pass holding a box a person drew; the 2026-09-03 rule) → every other pass, newest first, an import among them → legacy artifact geometry (boxes still read from an artifact, ranked among themselves by the 2026-08-25 type tiers). An import has no rank of its own (ruled 2026-10-04, #5443): a PAGE, ALTO, TEI or folder import is just the first pass on the page, ranked by date like a machine’s, and its maker is named where the file says it (the Mosquera DOCX draft was Qwen-VL’s). Until 2026-10-04 an “imported” tier sat above machine passes, so a geometry-free TEI import of a draft blanked Preview on 358 of 374 Mosquera pages. A pass without shapes is passed over for drawing (ui.preview.draws-a-pass-with-shapes): a pass whose segments are all shape: unstated (or have no box) goes behind every pass with shapes – behind, not out, so a text-only page still selects its own lines (built 2026-10-04, #5443; fichero/Tests/Unit/general/Models/WorkingPassRankingTests.swift). Since #5467 the engine does this ranking and serves its answer on each pass: drawn (the pass the image draws) and rank. A pass with no artifact behind it is ranked, never dropped: an imported page’s only pass has none, and dropping it is why an imported page showed its text and no boxes. The inspector’s focused artifact still goes first when it has boxes (2026-08-27). Code: SegmentDisplay.drawingOrder, the one function that orders passes for drawing (#5467): the focused artifact’s pass when it has a shape, then the engine’s drawn pass, then the rest by the engine’s rank. The app ranks nothing itself (fichero/Tests/Unit/general/Models/DrawingOrderTests.swift).

Ruling, the Inspector rethought from the archive model (2026-09-27), on the four questions of build-notes-inspector.md: 1. Verbs, not typing. The Inspector never types a reading or draws a shape. It offers verbs on the selection: reorder, Join, set language or hand, attribute. 2. A path head and the same sections at every level, empty sections hidden, as proposed. 3. Words can be reordered under a line, with the same verbs as lines (drag, ⌥⌘↑/↓, start / end), through the same ReadingOrderStore. 4. The eleven document tabs stay as the source level’s view. The Artifacts tab’s region rows fold into the Order section.

Slice 12 (the speed trial) is a hard gate before any of this.

Slice 12: the speed trial, and how it is measured

The trial is a hard gate: the editor is not built on a drawing approach that failed it. A gate needs a number that can be failed and a method that two people follow to the same answer, and this section is the method. It is written because the numbers were ruled and the method was not, and a threshold without a method is two people disagreeing politely. Three measurement mistakes in one week of this programme — a cause named from a single run, a 13% difference that was noise, and a figure quoted from a comment as though it had been measured — are what this is guarding against, and every rule below has one of them behind it.

What the numbers are. Ruled already, and not reopened here: sixty frames a second during zoom and pan, twenty thousand shapes, the oldest supported iPhone, and one edit through the engine and system undo in under a tenth of a second. Sixty frames a second is a budget of 16.7 ms per frame, and the gate is on the worst frame in the run, not the mean — a mean hides exactly the dropped frame a person feels. Hit-testing and dragging one point are each inside one frame’s budget, because they happen during a gesture.

Peak memory has no ruled ceiling and this spec does not invent one. The trial measures and records it on a very large scan, and the ceiling is set from that first measurement and ruled before the gate can fail anything on memory. What the trial can already fail on is growth: memory must not scale with the page’s shape count once only the visible shapes are drawn, and a run whose peak grows between 5,000 and 20,000 shapes in proportion to the count has failed regardless of the absolute figure.

Which page. One committed fixture, named in the test, and declared for what it is. Its shape count, its nesting (regions, lines, words, characters) and the distribution of shape sizes are taken from a real dense import measured for the purpose; the shapes themselves are generated, so that the fixture can live in the repository and so that the trial is repeatable. A generated fixture described as a real page would be the same lie as a number quoted from a comment, so the fixture’s own file records which real page its distribution came from.

Which machine. Two, and both are named in the result: the oldest supported iPhone (the ruled target) and the Mac the work is done on. A number with no machine beside it cannot be compared with next month’s number, so the record carries the device model, the OS version and the build configuration. Release, not Debug — measuring a Debug build and calling it the product’s speed is measuring the wrong thing.

How many runs, and which number counts. The first run is discarded as cold, then five runs, and the recorded result is the median of the five together with the worst frame seen in any of them. One run is not a measurement: a single figure cannot be told apart from noise, and this programme has already mistaken noise for a 13% regression. If the five runs disagree by more than a tenth of their median, the run is inconclusive rather than passing, and the answer is more runs or a quieter machine, never the best of the five.

What counts as the machine being idle. No build running, no background embedding, processing or conversion, no other lane’s test suite, mains power, and the thermal state recorded with the result. A run taken while the machine was thermally throttled is void, not failing — the two are different facts and recording a throttled run as a failure would send somebody to optimise code that was never slow. The trial refuses to record a result when it cannot read the thermal state, because a gate that cannot read its input must fail rather than pass quietly.

What the number is measured against. A committed baseline file, one line per measurement, the way the spec pipeline and the swallowed-exception seam keep theirs. A run is compared with the baseline and a regression beyond the noise band is a failure; a clear improvement updates the baseline in the same commit as the change that earned it, never in a commit of its own. The baseline records the machine, the OS, the fixture and the date, because a baseline that does not say what it was measured on is a number somebody will argue with.

What the trial may conclude. That a drawing approach holds, or that it does not, or that the target itself is wrong — the ruled sixty frames at twenty thousand shapes on the oldest iPhone may turn out to be the wrong gate, and the trial is allowed to come back and say so with its measurements. What it may not do is pass by lowering the target quietly.

Accessibility

Drawn shapes are invisible to VoiceOver unless the editor describes them. Each visible segment is an accessibility element with its kind, its reading and its place in the order, and every command that can be done with the pointer can be done from the keyboard.

Segment pictures

Wherever a segment appears (the Inspector, the transcription strip, a comparison, a training export) its picture comes from one engine call that cuts the image to the segment’s shape. There is one way to get a segment’s picture, used by the app, MCP, the command line and export.

Behaviors (every one is [GAP]: designed, not built; each cites its issue on milestone source-model, 322)

Reading before editing (the app’s first step: it draws from the seam, and edits nothing new) - source.app.one-segment-store — [OK] (→ #4954) one store in the app holds a document’s segments and passes, read from the engine’s one segments call; it is the only caller of that call, and nothing else in the app keeps segments. Models/SegmentStore.swift keyed by document id, the only caller of SegmentService, pinned by SegmentStoreTests.testLoadReplacesOnlyTheNamedDocumentsEntryLeavesOthersUntouched (that one row of the claim — one document’s entry replaced, every other untouched — is what a second store would break first). - source.app.index-is-the-engines — [OK] (→ #4954) a box on screen is addressed by the engine’s own index for it, never by its position in what happens to be drawn; leaving an undrawable box out of the drawing changes no other box’s address. Models/SegmentDisplay.swift keeps array position AS the engine index, so an undrawable segment stays at its own boxIndex as a zero-size placeholder rather than being dropped, and a pass whose indices have a gap or a repeat is refused whole rather than renumbered. Pinned by SegmentDisplayTests.undrawableSegmentStaysAsZeroSizePlaceholder, ::duplicateBoxIndexRefusesThePass and ::gapInBoxIndexRefusesThePass. An earlier draft gave boxes their own engineIndex field and a review reverted it: a dozen existing readers send an array offset straight to PUT …/regions, so a second index space would have desynced exactly the readers it was meant to protect. - source.app.edits-name-the-chosen-pass — [OK] (→ #4954) an edit made on the page is sent to the result the shown pass came from, and to no other; when what is shown changes (another pass wins; an artifact is chosen in the Inspector), the next edit follows it; with nothing shown, no edit is sent. All three clauses were already implemented and none was tested (audited 2026-09-27): every verb in ZoomableImagePreviewMac+Regions guarded on ocrGeometryArtifactId, the id of the artifact whose boxes are on screen — which since the seam wiring comes from the winning PASS — and clause 2 falls out of FocusedArtifact.shared.id being part of the preview’s .task(id:) identity, so choosing another artifact reloads the geometry and reassigns the id. The refusal is the clause that was untestable, because it lived inside view methods. It is now RegionEditTarget, extracted with the verbs’ behaviour unchanged, and pinned by RegionEditTargetTests.directEditWithNothingShownIsRefused, .selectionEditWithNothingShownIsRefused and — the one that stops the worst version — .aStaleSelectionIsRefused: a selection made against the PREVIOUS artifact is refused rather than retargeted, because indices are positions in one artifact’s box list and replaying them against another deletes different boxes. 9 tests. The stated exception is the marquee promotion: drawing a new region on a page with no geometry artifact creates a bare regions artifact first (2026-08-29), which is a creation rather than an edit sent to the wrong result. - source.app.curated-pass-stays-on-top — [OK] (→ #4954) a pass that a person made, or that carries any segment a person made, is shown ahead of every machine pass, as today; a newer machine run never covers a person’s region. SegmentDisplay.drawingOrder (until #5467, OCRGeometrySelection.rankedPasses) ranks a pass human when provenanceKind == .human or any of its segments is hand-curated, with a -1 override over the two type tiers and newest-first within a rank — through the same rankCandidates the artifact path uses, which is what makes it literally “as today” rather than a second ranking. Pinned by DrawingOrderTests.drawingOrderAuthorityBeatsRecency (moved from OCRGeometrySelectionTests; the artifact path’s rankCandidates it shared is deleted, #5467). - source.app.overlays-draw-from-the-seam — [PARTIAL] (→ #4954; an imported page’s boxes → #5146) the boxes drawn over an image and over a PDF page both come from that store through one shared function, with the same drawing code as today and no new overlay; a page looks the same before and after the switch. ~~The shared function exists and is tested and no view calls it~~ Corrected 2026-09-27 (#5146), read on disk: both drawing paths call it first – the image canvas in OCRGeometryOverlay.swift (loadOCRGeometry → SegmentDisplay.selected(for:store:)) and the PDF page in PDFPageView+OCRBoxes.swift – with the artifact path as the fallback. What kept an imported page’s boxes off the image was the ranking inside it: rankedPasses dropped any pass with no artifact type, which is every imported pass. Fixed by ranking such passes (the ladder above), and pinned end to end by ImportedPageDrawsItsBoxesTests over the engine answer that test_imported_page_draws_its_boxes.py records from a real PAGE import. Equivalence is pinned, which is the half a unit test can make (2026-09-27): for one page’s boxes, the seam and today’s artifact path agree field for field including isHandDrawn, so curation styling does not change the day it is wired (SegmentDisplayTests.seamMatchesTheArtifactPathForTheSamePage); the one intended divergence is an undrawable segment’s zero-size placeholder, which keeps every later box’s index and paints nothing (SegmentDisplayTests.theOneDifferenceIsThePlaceholderAndItIsInvisible). Wired 2026-09-27, both paths. Neither SegmentService nor SegmentStore was constructed anywhere in the app, so the seam had no instance to read: SegmentStore gained shared(for:) on ArtifactEntityStore’s keyed-singleton idiom, LibraryManager builds the service beside the others and registers the store with the change stream — without that registration apply/resync were dead code — and LibraryServiceEnvironment injects the service at the one boundary list whose own comment says “add new library services HERE and every boundary gets them”. The image overlay (OCRGeometryOverlay.loadOCRGeometry) and the PDF page (PDFPageView+OCRBoxes.loadOCRGeometry) now read the seam first and keep the artifact path only for a host with no library environment, which is the existing optional-service idiom in both files rather than a second source of truth. The seam had to grow one thing before it could replace the views, and stage 1 had named it: the inspector’s selection outranks the ladder (Daniel, 2026-08-27, “when I click on different regions in artifacts, should bounding boxes update?”), and SegmentDisplay’s own docstring called that “the acknowledged gap when this function is actually wired in”. It is now preferringArtifactId, a REORDER and never a filter — a focused artifact with no usable boxes falls through to the ladder, as the artifact path does. Pinned by SegmentDisplayTests.focusedArtifactOutranksTheLadder, .focusOnAnotherPagesArtifactChangesNothing and .focusedButUnusablePassFallsThrough. The focus override stands (ruled 2026-08-27). Since #5467 it lives in SegmentDisplay.drawingOrder, ahead of the engine’s drawn pass, still a reorder and never a filter: a focused artifact with no shape, or another page’s, falls through. The artifact-path fallback is deleted, and both loaders read the seam alone, which serves every unconverted result as a pass. Re-pinned by SegmentDisplayTests.focusedArtifactOutranksTheDrawnPass, .focusOnAnotherPagesArtifactChangesNothing and .focusedButUnusablePassFallsThrough, and through the real store by ImportedPageDrawsItsBoxesTests.testANewerPassDoesNotTakeThePreviewButAFocusedArtifactDoes. With an artifact focused, Preview can show a different pass from the Segments list and the text (an open question, below). SegmentDisplay.selected(for:store:) returns the geometry and the winning pass’s artifact id from one answer, because the curation verbs address the artifact whose boxes are on screen and a separate lookup could name a pass that did not win (SegmentDisplayTests.drawnPassNamesItsArtifact). - source.app.segment-events-patch-in-place — [PARTIAL] (#4954) when the engine says which segments changed, the store replaces those items and no others; when it says only that a document’s results changed, the store re-reads that one document. Built 2026-09-27 and not yet compiled by anyone — Swift, and the manager owns the build, so this stays [PARTIAL] until it has gone through one. SegmentStore conforms to ChangeEventConsumer over segment.* and pass.*; the decision is a pure SegmentStore.plan(for:heldSegmentIds:loadedDocumentIds:) returning patch / reload one document / nothing, because “those items and no others” is a claim about what the store CHOOSES and a test of the choice cannot be fooled by a coincidentally-correct fetch. SegmentService.segment(id:) reads one row so a patch does not need the page. Pinned by SegmentStoreTests.testAnEventNamingHeldSegmentsPatchesThoseAndDoesNotReload, ::testPatchWinsOverReloadWhenAnEventCarriesBoth (every segment event also names its document, so a document-first branch would turn every patchable event into a page reload and make the behaviour unimplementable — the ordering IS the behaviour), ::testPatchReplacesARowInPlaceWithoutMovingAnyOther and ::testAnEventAboutADocumentThisStoreNeverLoadedDoesNothing. The stage-1 deferral said this had to wait for the engine’s segment_ids/pass_ids fields to exist. They exist and are emitted — reading_orders.py (three sites), typed_links.py, segment_conversion.py, segments.py (three sites), content_representations.py — so the reason was true when written and went stale with last week’s work.

The editor - source.editor.segment-focus — [OK] (#4941; ruled on #5114) the segment focus is the tool bar the Preview already has (its markup row and the Edit Segments switch in What to show); there is no new mode, window or pane. It can sit beside a Reader, an Inspector, the Library or another Source view. Ruled 2026-10-04 (#5114): no new mode; retagged from what is built (below). The menu-bar command for the switch is owed under the menu rule (ui/menus-and-commands.md), and the drawing verbs not yet built (polygon, baseline, scissors) have their own behaviours. Ruled 2026-09-27 (#5114): segment editing is a mode of the Source view. It is switched on from the view’s what-to-show menu (Edit Segments) like the view’s other options, and it is not a window or a pane of its own. Off, which is the default, the page is for reading. The rule, in one sentence: with the mode off, nothing on the page changes. Selecting stays available, and so do the ephemeral marquees, which are run scopes and not segments, because choosing what to read or what a workflow runs on is reading. Every verb that writes a segment needs the mode: move, delete, combine, and naming a marquee or words into a region. The mode is per window, like the armed markup tool, so two Source views split in one window edit together. Built: WindowState.isEditingSegments; the menu toggle; the decisions in SegmentEditingMode (⌫ removes a picked marquee in either mode and deletes regions only in the mode; a press on a selected box moves it only in the mode; the markup row’s Delete and Combine act only in the mode), applied at the drag, the context menu, the marquee’s naming badge and double-click, ⌫, and the markup row. A defect found on the way: before this, the markup row’s Delete and Combine buttons, and Combine’s ⌘⌥C, posted .previewRegionVerb, and nothing in the app observed it, so both buttons did nothing. The Source view now observes it through the mode. Pinned by SegmentEditingModeTests.deleteAndCombineActInTheMode, SegmentEditingModeTests.outOfTheModeNoVerbEdits, SegmentEditingModeTests.deleteKeyNeverDeletesARegionWhileReading, SegmentEditingModeTests.aPressOnASelectedBoxMovesItOnlyInTheMode and SegmentEditingModeTests.aNewWindowOpensForReading. Was owed before the 2026-10-04 ruling (no new mode; kept for the record): the mode’s scoping beyond the verbs. That covers which surface’s selection the Inspector follows in the mode, and a menu-bar command with a shortcut (“every command is in the menu bar”). The drawing verbs that do not exist yet (polygon, baseline, scissors) will live inside the mode when they are built. The tests pin decisions; nothing yet drives the view itself. - source.textedit.reader-shows-segments — [PARTIAL] (#5001) for a source whose segments have readings, the text surface shows the lines of the working pass in the named reading order, one block for each region and direction, and is editable; with no Source view in sight each line shows its picture. (Home recommended: the Reader; a sixth pane kind is the other option; the maintainer’s to choose.) Read from the code, not the tag, 2026-09-27: the derivation is already whole — document_text() reads the working pass and follows the named order, spans and all (fichero-server/tests/unit/api/test_textedit_engine_primitives.py::TestReaderShowsSegmentsUsesTheWorkingPassAndNamedOrder::test_the_derived_text_follows_the_working_pass_and_names_its_segments). Built 2026-09-27: the packaging this line names — document_text() now also returns blocks: list[TextBlock], one block per region, split on a DIRECTION CHANGE within a region (not grouped by direction value alone): a whole-page-rtl region is one block (test_textedit_reader_blocks.py::TestOneDirectionIsOneBlock::test_a_whole_page_of_one_direction_is_one_block); a boustrophedon (alternating) region is one block per line, proven by the explicit alternating-lines test plus a same-direction run still merging within it (::TestADirectionChangeStartsANewBlock::test_boustrophedon_alternating_lines_are_one_block_per_line_not_one_merged_block, ::test_a_run_within_an_alternating_page_still_merges). Decision written down here per the spec: alternating and follows-baseline are both non-orientable and NEVER merge with a neighbour, even an identical one — each such segment starts its own block (::TestNonOrientableValuesNeverMerge::test_follows_baseline_is_one_block_per_segment, ::test_alternating_as_a_per_segment_value_is_also_one_block_per_segment). Region grouping (two same-direction regions still stay two blocks; a segment with no region parent gets a null region) is covered by ::TestRegionGrouping. No Source-view surface exists yet either way — this is the derivation and packaging only. Built 2026-09-28 (bugs lane), the Reader page’s half: the served page lays each page out in its direction and is editable where it has a line map (see every-direction and typing-is-a-new-reading below); a line move patches the page in place (fichero-server/tests/unit/api/test_reader_directions.py::test_after_a_move_the_caret_is_on_the_moved_line). Still PARTIAL: “each line shows its picture” is not built, and the screen path is not run end to end. - source.textedit.typing-is-a-new-reading — [PARTIAL] (#5001) typing corrects the reading of the segment under the caret as a new reading whose maker is the person, set by the engine; the earlier reading stays. The engine primitive is whole, tested against the request the app would actually send (a real actor, no run_id, no via_mcp) rather than a synthetic id: representation.create sets the maker from ActionContext via provenance_kind_from_ctx — the same function the machine-claims-stored-as-human defect (2026-09-18) fixed — never from client input (RepresentationCreateParams forbids a created_by/provenance_kind field outright), and no update action exists for a reading, so the earlier one cannot be touched. Stays PARTIAL, not OK: the SURFACE — a text edit that calls this action on keystroke-commit — does not exist, and #5001 (the umbrella this whole family is tracked under) is still open. Pinned: test_textedit_engine_primitives.py::TestTypingIsANewReadingSetsTheMakerFromContext (all four cases: a real actor recorded as human, a workflow run recorded as workflow even under the same actor name, a client-supplied maker refused outright, the earlier reading unchanged by a correction). What is not built is the SURFACE — a text edit that calls this action on keystroke-commit does not exist; that is #5001’s UI half, tracked separately. Built 2026-09-28 (bugs lane, #5154), the page’s half: the Reader is editable where it has a line map; a line’s typing is posted once, on leaving the line, blur, or before a split, join or move, as readingEdit {pageId, segmentId, text, previous, basedOn}, basedOn being the line’s counting reading; an edit that reaches another line is refused and the text put back (fichero-server/tests/unit/api/test_reader_typing.py::test_typing_in_a_line_is_that_line_and_nothing_else, ::test_an_edit_across_two_lines_is_refused, ::test_the_edit_message_names_the_reading_it_corrects, ::test_the_edit_message_is_enough_for_the_call_swift_makes). The correction then COUNTS (#5175, fichero-server/tests/unit/api/test_a_correction_counts.py). Still PARTIAL: the Swift bridge (archive, #5154) and one end-to-end run from the screen. - source.textedit.return-splits-the-line — [PARTIAL] (#5001) Return inside a line splits that segment at the caret in one action: the reading divides at the caret; the cut falls between words when their places are known, otherwise by proportion along the baseline and marked estimated; the first part keeps the id. Read from the code, 2026-09-27: segment.split already takes an independent geometric anchor and text reading_span per part — the primitive a Return handler would call — pinned by test_textedit_engine_primitives.py::TestReturnSplitsTheLineSegmentSplitPrimitive::test_a_split_takes_an_independent_anchor_and_reading_span_per_part. Not built, confirmed absent by search rather than assumed (::test_no_caret_to_geometry_mapping_exists_in_the_tree): the mapping from a caret’s character offset to a geometric cut point, in any direction — word-aligned when word children exist, proportional-along-the-baseline and marked estimated otherwise. This is a design decision (what the estimate looks like, how it behaves for RTL and boustrophedon text) and is not implemented anywhere in the tree. Built 2026-09-28 (bugs lane, #5154): the proportional estimate. segment.split(at_offset=) cuts the counting reading at the character and the box in proportion to it along the line’s resolved direction – rtl from the right edge, ttb from the top – both parts marked metadata.cut = "estimated" (fichero-server/tests/unit/api/test_return_splits_the_line.py::test_an_rtl_line_splits_from_the_right, ::test_a_column_splits_from_the_top, ::test_an_offset_outside_the_line_is_refused); the absence pin became test_textedit_engine_primitives.py::TestReturnSplitsTheLineSegmentSplitPrimitive::test_the_one_offset_to_geometry_mapping_is_the_estimated_cut. The page posts lineSplit {pageId, segmentId, offset} on Return (fichero-server/tests/unit/api/test_reader_typing.py::test_return_splits_at_the_caret_in_the_lines_own_text). Still PARTIAL: the word-aligned cut when word children exist, boustrophedon, and the Swift call. - source.textedit.backspace-joins-in-reading-order — [PARTIAL] (#5001) Backspace at a line’s start joins it to the line before it in the reading order, in one action, keeping the earlier line’s id; refused with the reason across regions or passes. segment.merge is the primitive, and as of 2026-09-27 both refusals are built: it already refused across pass/document (SegmentPassMismatchError); the across-REGION refusal did not exist until this session — _action_merge never checked parent_segment_id at all, so two segments from different regions merged silently. Added SegmentRegionMismatchError, checked right alongside the pass/document check, with the ordinary same-region case proven unaffected (fichero-server/tests/unit/api/test_segments_write_actions.py::TestMergeRefusesAcrossRegions, 3 tests: two different regions refused, one region + one no-region refused, same region still merges). Still not built: “keeping the earlier line’s id” is not automatic — keep_id is caller-chosen (proven by TestMergeKeepsTheKeptRowUntouchedForOrdering::test_merge_accepts_the_caller_chosen_keep_id_verbatim), so a Backspace handler choosing “the earlier line” is the caller’s job, not this primitive’s; no Source-view surface exists to call it from either way. Built 2026-09-28 (bugs lane, #5154), the page’s half: Backspace at a line’s start (never the page’s first line) posts lineJoin {pageId, segmentId, intoSegmentId}, intoSegmentId the line before it in the page’s text order, for segment.merge keeping it (fichero-server/tests/unit/api/test_reader_typing.py::test_backspace_joins_only_at_a_lines_start_and_never_the_first). Still PARTIAL: the Swift call. - source.textedit.deleting-words-keeps-ink — [PARTIAL] (#5001; #5190) removing text is a new reading without those words; no segment is deleted by it; a segment that has no reading, or an emptied line, is shown as such; deleting a segment is a separate, named command. Page half built (2026-09-28): an emptied line is a new, empty reading through representation.create, stays in the line map as a zero-width line, and is drawn in its place as an empty line under its segment (.line-empty, a drawn placeholder, so no offset moves); a caret there is on that line (fichero-server/tests/unit/api/test_reader_runs_and_empty_lines.py::test_a_line_whose_words_are_all_deleted_stays_on_the_page). App half built: a readingEdit that removes words, down to an empty line, only ever becomes representation.create, never a segment action, and the line stays (ImportedPageDrawsItsBoxesTests.testDeletingWordsIsANewReadingAndNeverTouchesTheSegments, over the recorded Syriac page). The joint (2026-09-28): the served page’s OWN script, run in node on the imported Syriac page, posts through its own notify (test_imported_page_draws_its_boxes.py::test_the_served_page_s_own_messages_are_recorded_for_the_app_s_bridge, regenerated every run, failing on drift), and those exact bodies go through the bridge’s own parse and ReaderTextEditRunner – what applyTextEdit runs – to the requests the engine takes (ImportedPageDrawsItsBoxesTests.testTheServedPagesOwnMessagesBecomeTheRequestsTheEngineTakes) (every word deleted is an empty representation.create, never a segment action). A segment with no reading, shown as such (built 2026-09-28): a line, word or character with no counting transcription (SegmentsPane.lacksReading) is drawn hollow and dashed on the image, never hidden, and still picked by a click; the Segments pane lists it “Line 17 · No reading”; the Inspector’s Text says “No reading” and offers Type a Reading… (representation.create, ⌘Z), after which the mark goes. An emptied line is not marked: it has a reading, an empty one. Pinned on the imported Syriac page with a line drawn by its baseline (test_imported_page_draws_its_boxes.py::test_a_segment_with_no_reading_is_listed_as_such_and_typing_one_gives_it_text, ImportedPageDrawsItsBoxesTests.testASegmentWithNoReadingIsShownPickedAndListedAsSuchAndTypingOneClearsIt). Worded as “a segment that has no reading” (ruled 2026-09-28 by the lead, default accepted): words keep their OWN readings (import writes one per word) and the Reader edits lines, so deleting every word of a line leaves its word segments WITH their readings, stale against the line. Whether to show that disagreement, retire those word readings, or leave it was the maintainer’s question. Ruled 2026-09-28: retire — see “When a Reader edit takes a word’s text out of its line” below (#5190). When a Reader edit takes a word’s text out of its line (#5190).

Ruled by the maintainer, 2026-09-28: when a Reader edit takes a word’s text out of its line, the engine retires that word’s reading. The behaviours below are the spec for that ruling; the engine half is built (2026-10-01). The defaults marked “(default)” are the lead’s reading of the ruling, and the maintainer may revisit them.

What exists today, read from the code on 2026-09-28 and not yet run to confirm:

  • Import writes one reading per word, from PAGE TextEquiv or ALTO String@CONTENT. A word reading holds no position in its line’s text.
  • The page text’s read_through_children (segment_readings.py) skips a line whose children have text, so that line’s text on the page comes from its words.
  • The line map (page_text_cache.line_map) folds those word spans into the line and gives the line representation_id = None (“read from its words”).
  • So a line reading typed in the Reader over such a line creates a representation.create that the page does not read: the words still win. Whatever else #5190 builds, an-edited-line-reads-from-itself has to come first. (Correction 2026-10-01: the recorded Syriac page has no line of several words with their own text, nor has the Clm ALTO – one text-bearing “word” per line. The tests use the corpus’s Transkribus German Fraktur PAGE page, 524 words with Word/TextEquiv over 74 lines.)

  • source.textedit.word-spans-in-the-line — [OK] (→ #5190; pinned by fichero-server/tests/unit/models/test_word_spans.py; runs are token ranges, not yet UTF-16 offsets, as nothing reads them) What the words held. The line’s text before the edit (“the base”) is what the page showed:

  • its counting line reading, if it has one;
  • otherwise its words’ counting readings joined with single spaces in text order. The derived text’s DerivedTextSpans already produce this.

Each word with a counting reading holds one span [start, end) of the base. Offsets are UTF-16, as for every anchor (core/utf16_offsets.py). The engine works the mapping out when the edit lands and never stores it. It is always recomputable from the base, the new line reading and the words’ readings, so a stored copy could only go stale (source.derived.recomputable).

The line’s base is the reading the edit corrects: corrects_representation_id, or the words when basedOn is None. A later edit to the same line maps against the previous edit’s line reading, not against the words again. - source.textedit.a-word-leaves-the-line — [OK] (→ #5190; pinned by fichero-server/tests/unit/models/test_word_spans.py, one test per case below) What “leaves the line” means. The base and the new line reading are both split into whitespace-separated tokens. The tokens are aligned in order, the same difflib.SequenceMatcher(autojunk=False) alignment as views.diff_word_tokens. A word stays only when: - the whole of its span falls in an equal run of that alignment; and - its text is unchanged, compared after Unicode NFC normalisation and nothing else.

Every other word leaves, and its reading is retired: - Deleted. It is gone. - Changed. One letter, a case change or punctuation fused to it all count as its text leaving (default). The word’s reading records ink, and “ſ” is not “s”. A person who corrects a word in the line has corrected the line, not that word. - Moved. A word taken out and typed elsewhere in the same line is delete plus insert in the alignment, so it leaves (default). Its old span is gone, and matching it to a new one by text alone would pick the wrong one of two equal words. - Split or joined. “and the” typed as “andthe”, or the reverse, leaves both words.

Whitespace inside the line never counts: a b is a b. A word that stays gets its new span in the new line reading. Words with no counting reading are neither mapped nor retired: nothing of theirs was on the line.

The rule is the same in every direction and script. The base is in logical order, never visual, so right-to-left and vertical lines need no case of their own. - source.textedit.an-edited-line-reads-from-itself — [OK] (→ #5224; pinned by fichero-server/tests/unit/api/test_an_edited_line_reads_from_itself.py) The edit is what the page shows. Once a person’s line reading counts for a line, the page text, line map and export read that line from its own reading, not from its words. read_through_children stops applying to it, and line_map gives it that representation_id, so the next edit corrects it.

Words that stayed keep their readings and are marked on the image and in the Segments pane as before. This is the only way the ruling can hold. If the words still carried the page, retiring some of them would drop text the person just typed, and keeping all of them would drop the edit. A machine’s line reading changes nothing here, because a machine’s reading is never the record over a reading it disagrees with (#5175). - source.textedit.retiring-is-part-of-the-edit — [PARTIAL] (#5190; built 2026-10-01: worked out at read time in counting_by_kind / retired_word_readings, so the Inspector’s readings route, the Segments pane’s counting_texts and every per-segment reader agree; undo brings the word back and a word given a reading after the edit counts – fichero-server/tests/unit/api/test_a_word_left_out_of_its_line_is_retired.py. the audit row names them (after.retired_word_segment_ids, and they ride the change’s segment_ids so open windows redraw) – test_the_audit_names_the_words_the_edit_retired. redo retires again (test_redo_retires_the_word_again). Still to check: the app’s panes reading the same after edit, undo and redo) One action, one undo. Retiring happens inside the same representation.create that saves the line reading. It is one audit row and one ⌘Z, never a second action or a background job.

A retired word reading is not retracted and not deleted. Its row stays, its retracted_at stays empty, and its segment stays. It stops counting for the same reason a corrected line does: a person’s newer reading of the thing it is part of left it out. The action’s ChangeSpec names the retired word ids, so the audit says which words the edit retired.

Undo. ⌘Z retracts the line reading, as today. With it gone, nothing retires those words, and they count again. Retirement is worked out from the counting line reading at read time, in the same way that a withdrawn correction retires nothing (models/readings.py, #5175), so undo writes nothing about words and cannot miss one. ⌘⇧Z brings the line reading back and retires them again. After an edit, its undo and its redo, the page, the Segments pane and the Inspector each read exactly as they did at that point, on the recorded Syriac page.

A person can also give a retired word a reading of its own later: Inspector ▸ Type a Reading…, or a word pass. That reading is newer than the line reading and counts. “Retired” applies only to the readings that existed when the line was edited. - source.textedit.a-retired-word-in-the-inspector — [GAP] (#5190) What the Inspector shows. Select a retired word on the image or in the Segments pane. The Inspector’s Text section shows its reading, labelled Retired, with the sentence “Taken out of the line by your edit of “. The reading is struck through in secondary style and still selectable, the same as the equal-alternatives listing. The word is not hidden.

The section offers: - Show the Line, which selects the line and names the reading that retired the word; - Type a Reading…, as for a segment with no reading.

The image draws the word hollow and dashed, like a segment with no reading, because it now has no counting text. The Segments pane lists it “Word 4 · Retired”, which differs from “No reading”: the word had a reading and the edit took it out.

A strict project shows the same thing. Retirement is not a disagreement, so it never makes a line’s text vanish. - source.textedit.retired-words-in-the-export — [PARTIAL] (#5190; built 2026-10-01 in page_export.page_from_library: a retired reading is dropped before the segment is written, so a retired word goes out with its shape and no text, and is no alternative either – PAGE XML pinned by test_a_word_left_out_of_its_line_is_retired.py::test_the_export_writes_a_retired_word_with_its_shape_and_no_text. ALTO (String CONTENT="") and hOCR (an empty ocrx_word) pinned by test_alto_and_hocr_write_a_retired_word_with_no_text. Still GAP: TEI – once a person’s line text differs from its words joined, the TEI writer takes its no-words branch, so a retired word’s zone is not yet written at all – and the library-to-library round trip below) The export. Every exporter writes what the page shows: - the line’s text is its counting reading; - a retired word is written with its geometry and no text: - PAGE XML: a Word with its Coords and no TextEquiv; - ALTO: String CONTENT="", the same form an untranscribed word already takes (→ #5130, alto.py); - hOCR: an ocrx_word with empty text; - TEI facsimile: a zone with no text.

Words that stayed keep their text. Retired readings are not written as alternatives either, because PAGE XML’s TextEquiv@index cannot say which one counts, and every alternative would read as a live transcription.

The round-trip check follows the rule that a round trip is tested per path, with a library in the middle. Import the recorded Syriac page, edit a line so that one word leaves, then export and import the result into a second library. That library’s page must read the edited line, carry the retired word as a segment with no reading, and carry every word that stayed with its text.

  • source.textedit.lines-move-in-the-order — [PARTIAL] (#5001) cutting and pasting whole lines changes the named reading order and nothing on the page; other pasted text is typing, its line breaks turned to spaces. Partly built (2026-09-27/28): ⌥⌘↑/↓/⇞/⇟ on the caret’s line posts lineMove, which the app turns into the Inspector’s reading_order.place (archive, 3c: fichero-server/tests/unit/api/test_reader_line_map.py::test_the_move_keys_name_the_caret_line_and_a_step_the_app_knows); the page is then patched in place with the caret back on the moved line, not reloaded (#5170, fichero-server/tests/unit/api/test_reader_directions.py::test_after_a_move_the_caret_is_on_the_moved_line), and across a move AND its ⌘Z the scroll is kept by name and the caret ends where it began (::test_a_move_and_its_undo_keep_the_scroll_and_the_caret, the page’s own refreshPage on a stand-in DOM; how WebKit scrolls still needs a look in the app). Still PARTIAL: cutting and pasting whole lines is not built.
  • source.textedit.one-selection — [PARTIAL] (#5001) the caret’s line (and word) is the selection in the Source view, and a selection there selects the text; one shared selection. Built 2026-09-28 (bugs lane, #5155), the page’s half: the page posts lineFocused once per change of the caret’s line, and window.fichero.showLines(ids) draws the app’s selection over those lines as a CSS Custom Highlight – no element wraps a line, the caret does not move (fichero-server/tests/unit/api/test_reader_selection.py::test_focus_is_posted_once_per_line_change, ::test_the_apps_selection_names_exactly_those_lines_text). Still PARTIAL: the app’s half (archive, f5f580761) and a run from the screen; the word-level selection is not built.
  • source.textedit.a-run-of-keys-is-one-action — [PARTIAL] (#5001) typing in one line commits as one reading, one audit record and one undo step, on leaving the line, a structural key, loss of focus, Save, or two seconds’ pause; structural edits are their own action at once. Page half built (2026-09-28): the page sends one readingEdit per run: on leaving the line, a structural key, blur (#5154), two seconds without a key, and ⌘S or the app’s Save (window.fichero.commitPending()); a key after a commit starts a new run based on the reading the commit made (lineCommitted.representationId); words typed while the page is re-read are kept on their line and sent with their run (fichero-server/tests/unit/api/test_reader_runs_and_empty_lines.py::test_a_pause_of_two_seconds_ends_the_run_and_the_next_key_starts_another, ::test_the_next_run_is_based_on_the_reading_the_last_one_made, ::test_words_being_typed_survive_the_page_being_re_read). App half built: one readingEdit is ONE representation.create, one audit row and one ⌘Z step, and the page is told the reading it made (ImportedPageDrawsItsBoxesTests.testARunOfTypingIsOneReadingOneAuditOneUndo, over the recorded Syriac page); the engine half is representation.create itself. The joint (2026-09-28): the served page’s OWN script, run in node on the imported Syriac page, posts through its own notify (test_imported_page_draws_its_boxes.py::test_the_served_page_s_own_messages_are_recorded_for_the_app_s_bridge, regenerated every run, failing on drift), and those exact bodies go through the bridge’s own parse and ReaderTextEditRunner – what applyTextEdit runs – to the requests the engine takes (ImportedPageDrawsItsBoxesTests.testTheServedPagesOwnMessagesBecomeTheRequestsTheEngineTakes).
  • source.textedit.stale-keeps-your-words — [PARTIAL] (#5001) an edit against a version that has moved on is refused and the typed words are kept and offered: keep mine, take theirs, compare; out of reach of the engine the text is read-only. The token (decided 2026-09-28 by the lead as a default; the maintainer may revisit): a write carries expected_counting_id, the id of the reading that counted when the person began typing. representation.create compares it with what counts now (counting_by_kind); if another reading counts, it answers 409 {"reason": "stale", "counting_representation_id", "counting_text", ...} and writes nothing. Keep Mine is the same words sent again against the reading that counts now. A write without the field behaves as before: two corrections of one reading, neither aware of the other, both land as candidates. MCP, the command line and imports send no token. Built: the engine (test_stale_keeps_your_words.py, 3 tests on the imported Syriac page: a stale write writes nothing and names what counts; Keep Mine against the new basis lands and counts; no token behaves as today; a token without a segment is refused), plus test_textedit_engine_primitives.py::TestWithoutTheTokenTwoCorrectionsBothLand. Built: the page (2026-09-28): on lineCommitted({ok:false, stale:true, mine, theirs}) the line keeps the person’s words, marked (::highlight(fichero-stale-lines)), with Keep Mine (the same words re-sent with basedOn = theirs, which lands), Take Theirs (the library’s line, re-read) and Compare (theirs struck against mine, word by word); a stale line’s commits are HELD, never re-sent (no loop), and its words survive any re-read of the page until the person chooses (fichero-server/tests/unit/api/test_reader_stale_keeps_your_words.py::test_keep_mine_sends_the_same_words_against_what_counts_now_and_it_lands, ::test_a_stale_line_is_held_never_re_sent, ::test_a_re_read_keeps_the_stale_words_on_their_line, ::test_compare_shows_theirs_struck_against_mine_and_the_bar_offers_three_choices). Built: the app sends basedOn as the token (ReaderTextEdit.newReading); a 409 is answered to the page as lineCommitted({ok:false, stale:true, mine, theirs:{representationId, text}}), with what counts re-read from the engine and no page refresh over the typed words (ReaderTextEdit.staleAnswer; ImportedPageDrawsItsBoxesTests.testATypedLineAgainstAReadingThatNoLongerCountsKeepsTheWordsAndNamesWhatCounts, recorded Syriac readings: token sent, 409 answered with the words kept and rep-0002 named, Keep Mine re-sent against it). A line whose text came from its words sends no basedOn, so no check. The joint (2026-09-28): the served page’s OWN script, run in node on the imported Syriac page, posts through its own notify (test_imported_page_draws_its_boxes.py::test_the_served_page_s_own_messages_are_recorded_for_the_app_s_bridge, regenerated every run, failing on drift), and those exact bodies go through the bridge’s own parse and ReaderTextEditRunner – what applyTextEdit runs – to the requests the engine takes (ImportedPageDrawsItsBoxesTests.testTheServedPagesOwnMessagesBecomeTheRequestsTheEngineTakes): Keep Mine’s own message is representation.create against what counts now. Out of reach, built 2026-09-28 (both halves): a typed line that cannot reach the engine (the transport fails; an HTTP refusal is not “out of reach”) is answered lineCommitted({ok:false, unreachable:true, reason}) and the page is told engineState({reachable:false, reason}): it holds every edit and goes read-only. The engine’s health is asked every few seconds; when it answers, the page is told engineState({reachable:true}) and posts its held edits as ordinary readingEdits, checked against what counts as always (a line changed meanwhile comes back stale). Page half: bugs2 fce4a9241 (test_reader_out_of_reach_is_read_only.py::test_out_of_reach_the_page_holds_every_edit_and_posts_nothing, ::test_back_in_reach_every_held_edit_is_sent_again_with_its_version_check, ::test_a_held_edit_whose_line_moved_on_meanwhile_comes_back_stale, ::test_through_the_page_s_own_bridge_nothing_is_posted_out_of_reach_and_the_resend_is_the_same_body, including on the joint’s harness: nothing reaches the bridge while out of reach, and the resend is byte-identical). App half: the page’s own typing message through the real generated client with the transport failing, and the return probe (ImportedPageDrawsItsBoxesTests.testATypedLineThatCannotReachTheEngineIsHeldNotLost).
  • source.textedit.a-line-per-line — [PARTIAL] (#5208) the Reader shows one line per manuscript line, each in its direction; a region’s lines never flow together as a paragraph (Daniel, 2026-09-28: a diplomatic reading keeps the manuscript’s lines). In a ttb page each line is its own column. Built 2026-09-28, the page’s half: each separator between two consecutive lines of the line map is wrapped as a .line-break, and the page’s CSS draws a line break after it. The break is never a character, so the text nodes are the page text to the character and no caret, claim or search offset moves. The three highlighters (claim, span, search) looked for their text in ONE text node; they now find it across nodes, one mark per node it touches (excerptPieces, offsetPieces, wrapPieces), so a claim across a line boundary is still lit whole. Pinned by test_reader_directions.py::test_each_manuscript_line_is_its_own_line_and_the_text_is_unchanged (the recorded Syriac page) and test_reader_line_map.py::test_a_claim_across_two_lines_still_has_one_text_to_paint. Still PARTIAL: a look in the app. The caret’s movement across a break in WebKit is unverified, as is typing with the caret at a line’s end.
  • source.textedit.every-direction — [PARTIAL] (#5001) each block is laid out and edited in its own direction; line starts, joins and cuts follow reading order and the baseline; a direction the platform cannot lay out is labelled, never reordered. Built 2026-09-28 (bugs lane, #5147 Reader half, #5171, #5172): each Reader page is laid out in its direction – rtl as dir="rtl", ttb as writing-mode: vertical-rl, a run in the other horizontal direction as a <span dir> isolate – resolved at render so a setting shows at once (fichero-server/tests/unit/api/test_reader_directions.py::test_an_rtl_page_is_laid_out_right_to_left, ::test_a_latin_line_on_a_syriac_page_is_its_own_isolate, ::test_a_page_of_columns_is_laid_out_in_columns, ::test_a_direction_set_on_the_page_reaches_the_reader_at_once); a line with no letters takes its block’s direction, else its page’s, and says so (fichero-server/tests/unit/api/test_a_line_without_letters_takes_its_pages_direction.py); the Inspector’s resolved direction reads the same line shapes, so a column page is ttb in both, never “ltr from the script” beside a vertical Reader (#5202: fichero-server/tests/unit/api/test_vertical_pages_read_top_to_bottom.py::test_the_inspector_and_the_reader_agree_on_the_real_page); a Return cut follows the direction (return-splits-the-line). The app re-reads the Reader’s page when a direction is set from the Segment menu or the Inspector, and on that set’s ⌘Z and ⇧⌘Z – once per change, only a page the Reader shows, never a poll: every SegmentEditRunner change posts didChange, and the Reader’s SegmentChangeObserver patches that page in place (ImportedPageDrawsItsBoxesTests.testADirectionSetItsUndoAndItsRedoEachReloadTheReadersPageOnce, Syriac). A direction stated on a source comes from the Library’s right-click or the Inspector (89bde37b4). Matrix on real corpus files: acceptance-2026-09-27.md § Directions in the Reader. Still PARTIAL: columns that advance left to right (Mongolian) cannot be said yet (#5173; ruled 2026-10-04 to be ttb-lr, see languages-scripts-signs.md source.dir.ttb-lr); “a direction the platform cannot lay out is labelled” is not built; not run on the screen.
  • source.text.bundled-fonts — [PARTIAL] (#5210, subsumes #5206) a letter the system fonts lack is drawn by a bundled open font before it becomes ⍰ or ▦. Built 2026-09-28 (engine half): the engine ships Junicode (MUFI, medieval Latin), Noto Sans Syriac (Estrangela, Western, Eastern), Noto Sans Mongolian, Coptic and Cherokee – each the upstream file unmodified, SIL OFL 1.1 with its own OFL text, sha256 and source in resources/fonts/PROVENANCE.md, 1.3 MB in all – serves them at GET /api/fonts (the list, in fallback order) and GET /api/fonts/{file} (font/otf, behind the engine’s auth like thumbnails), and the Reader declares each by @font-face and names them after the system fonts in its stack. Pinned by fichero-server/tests/unit/api/test_bundled_fonts.py: every MUFI letter of Clm 13027 f. 38r (U+F1AC among them) is in Junicode’s own cmap, each font draws a letter of its script, the page declares every face. [GAP] (#5210): the app registering the same files for the Inspector, Segments rows and on-image labels; the per-script font choice through the cascade; not checked on the screen.
  • source.textedit.no-second-path — [GAP] (#5001) every change made from the text is one of the existing segment, reading and reading-order actions; the text surface defines none of its own.
  • source.segments-pane.exists — [GAP] (#4942; approved 2026-09-27) a Segments pane kind lists the segments of the page (or source) in scope beside the Preview, never inside it, as rows of kind, reading and place in the order; a row opens to its children, with a path back up.
  • source.segments-pane.selection-shared — [GAP] (#4942) picking rows in the Segments pane makes them the focused Source view’s selection on that page, the same selection the Order list and the Reader write, so the boxes light up and the Inspector follows.
  • source.segments-pane.reorders — [GAP] (#4942; Q5) the Segments pane reorders by drag-and-drop and by ⌥⌘↑ / ⌥⌘↓ / ⌥⌘⇞ / ⌥⌘⇟ through the same reading-order store and verbs as the Inspector’s Order list, each change undoable with ⌘Z.
  • source.segments-pane.views — [GAP] (#4942) the Segments pane shows its segments as a list, a strip or a grid of their pictures with their readings.
  • source.segments-pane.gathers — [GAP] (#4942) the Segments pane shows a gathered set that is not one page’s: every segment in one hand, every instance of a sign, marks of one character.
  • source.segments-pane.same-actions — [GAP] (#4942) every change the Segments pane makes is an existing audited action (the same the Source view’s editor and the Order list make); it defines none of its own.
  • source.segments-pane.follows-the-working-pass — [PARTIAL] (#5465, #5467) the Segments pane lists the segments of the page’s working pass only, the pass the engine names, and follows it. When a run’s result becomes the working pass while the page is open, the list re-reads that page’s orders, without reopening the page and without touching any other page. Built 2026-10-05: SegmentStore.workingSegments is the one pass-filtered read, and ReadingOrderStore.loadFollowing re-reads when the working pass changes. Pinned through the real pane in the library window’s tree by fichero/Tests/Unit/general/Models/ImportedPageDrawsItsBoxesTests.swift (testPreviewTheSegmentsListAndTheReaderNameTheSamePassOnAPageWithTwoPasses, testAPassChangeEventMovesThatPagesSegmentsListToTheNewWorkingPassInPlace). PARTIAL until seen on screen: a Detect Segments run finishing on an open page.
  • source.editor.library-lists-segments — [GAP] (#4941) the Library can list segments as rows, so project-wide questions about segments are ordinary Library searches. Genuinely absent on BOTH sides, and the engine side blocks the app side (read on disk 2026-09-27). The app’s axis exists and was designed for exactly this: LibraryContentKind is documents | claims | entities, and its own comment says “the north-star is that a claim and an entity are NODES that flow through the same library views and the same sidebar as a document”. Adding .segments is the shape the file already anticipates — one case, plus the table’s rows and a data source. The engine has no read to back it. Every segments route is per-document or per-segment: GET /api/segments/document/{doc_id}, /{segment_id}, /{segment_id}/versions, /{segment_id}/reference. There is no scoped listing (a folder’s, a project’s) and segments are not in the search index, so “project-wide questions about segments are ordinary Library searches” has nothing to ask. A Library that listed segments by fetching each document’s segments in turn would be the N+1 the one-store seam was built to avoid. So the buildable order is: a scoped, paged segment read on the engine, then the search leg, then the LibraryContentKind case. Naming it rather than starting at the app end, because starting at the app end is what produces a view that fetches per document. The first step is built (2026-09-27): GET /api/segments. A bounded page across a scope — either explicit document_ids or a parent_id whose descendants are walked — with kind, pass_id and include_furniture filters, answering {items, count, total, document_ids}. Pinned by test_scoped_segment_listing.py (11 tests). The parts worth knowing:
  • Database.segments_page does the WHERE, COUNT, ORDER, LIMIT and OFFSET in DuckDB, in the persistence layer — the architecture rule that raw SQL lives behind a typed method — so a project-wide list never hydrates a folder’s rows to answer one page.
  • the order is document_id, bbox_y, bbox_x, id — down and ACROSS each page, then the id as a total tie-break. Stable, so a page break cannot show one row twice while never showing another (created_at cannot do that: rows written in one transaction share a timestamp), and it is the same order _segment_order_key uses, so a scoped list reads the way the page reads. Not box_index, which is not a column at all: a real row’s engine index lives in metadata["box_index"] and is only meaningful within one pass.
  • limit is capped at 1000 in the signature (source.store.bounded-reads), and document_ids comes back so a caller can tell “these 40 documents have no segments” from “that folder has no documents” — both of which otherwise arrive as no rows.
  • REAL rows only. The per-document route also serves unconverted artifacts’ boxes (source.seam.read-either-store), and that resolution reads one artifact’s block per artifact; doing it across a scope would hydrate every geometry blob in the folder, which is the cost this endpoint exists to avoid. The route’s docstring says so rather than appearing to list everything. Still [GAP]: the search leg and the Library’s own .segments case are owed.
  • source.editor.one-overlay — [PARTIAL] (#4941) one component draws and edits segments in the Source view; no second overlay renderer exists in the app. One SOURCE of geometry as of 2026-09-27, and two RENDERERS — which the behaviour as written forbids and the platform requires. Both surfaces now take their boxes from SegmentDisplay (source.app.overlays-draw-from-the-seam), so there is one decision about what to draw. But the drawing itself is OCRGeometryOverlay’s SwiftUI Canvas on an image and PDFAnnotation squares on a PDF page, and the reason is recorded where the second one lives: “AppKit’s PDFView has no coordinate space a SwiftUI overlay can lay out in”. A third renderer does not exist — the Inspector’s regions panel lists rows and draws nothing. So the sentence as written cannot be satisfied while the app uses PDFView, and leaving it is leaving a behaviour that can never go [OK]. Proposed wording, awaiting the maintainer (with #5114 and #5115, which are the same kind of question):

    source.editor.one-overlay — one component decides WHAT to draw and edit for segments, and each surface kind has exactly one renderer of it: the SwiftUI Canvas for images, PDFKit annotations for PDF pages. No second decider, and no second renderer per surface kind. That is true today and it keeps what the original sentence was protecting — a page cannot be drawn from two different ideas of what its segments are — while admitting the one thing the platform imposes. The word doing the work is DECIDER: two renderers are a fact about coordinate spaces; two deciders would be the defect. If the maintainer would rather keep the sentence as written, the honest consequence is that the behaviour is [BROKEN] rather than [PARTIAL], and the work it implies is replacing PDFView with a SwiftUI page renderer — which is a much larger decision than this spec carries.

  • source.editor.shapes-in-source-view — [PARTIAL] (#4941; ruled on #5115) a segment’s shape is edited in the Source view and its readings are typed in the Reader. The Inspector may offer the same audited verbs as the Source view on what it lists, never a second implementation. Ruled 2026-09-27 (#5115): the Inspector’s Combine stays, because a panel that holds other things keeps its verb there. The earlier sentence (“the Inspector shows and does not edit”) was too strong. What the ruling forbids is a second implementation. As of this change both surfaces call the same service verb (ArtifactService.combineRegions) and register ⌘Z through the same RegionEditResult.registerUndo. One duplicate remains, named so it is not mistaken for compliance: the Inspector checks selection.artifactId == artifactId itself instead of sharing the Source view’s check. Readings typed in the Reader has no surface yet (source.textedit.*, #5001), and that is why this stays PARTIAL.
  • source.editor.thin-by-default — [PARTIAL] (#5207) How an unselected segment looks. On a dense page, filled boxes overlapped into stacked bands (Daniel, the clm13027 MUFI page).

By default a segment draws: - a THIN outline, one screen point wide (two with Increase Contrast); - its baseline, when it has one.

The fill appears only on hover or selection. A segment with no reading keeps its dashed hollow outline (deleting-words-keeps-ink), and an uncertain one keeps its dashed stroke. Regions stay distinct from lines by colour (regions-in-colours), not by fill. Built 2026-09-28 (d046a7a73), image pages: no fill at rest, and hover now washes (DocumentOverlayView). Still PARTIAL: the PDF page’s boxes, and the maintainer’s look on the clm13027 page. - source.editor.regions-under-lines — [OK] (→ #5284, ruled 2026-10-01; the look on screen pending) A page with regions and lines draws both, the regions lighter. The preview used to draw only the finest level a page had (words, else lines, else everything), a rule from before regions and lines were segments of one page, so a page’s regions vanished the moment it had lines. Now: words when the page has words (as before); otherwise its lines AND its regions, each region’s outline at a lighter strength than its lines, so the structure reads without competing with the lines. A click inside a line still picks the line (the smallest box wins); a click in a region outside every line picks the region. Built in OCRGeometry.displayIndexedBoxes (words, else every box) and DocumentOverlay.strength(ofKind:linesShown:) (a region under lines at 0.4, read by the image overlay and the SwiftUI canvas alike). Pinned by fichero/Tests/Unit/general/Models/RegionsUnderLinesTests.swift and the real-window ImportedPageDrawsItsBoxesTests.testTheRealPreviewInTheLibraryWindowsTreeDrawsTheImportedPagesRegionsAndLines (the Syriac page’s 4 regions and 12 lines, drawn and in the accessibility tree). - source.editor.regions-in-colours — [PARTIAL] (#5200) Regions in distinct colours.

The palette: every region is drawn in a colour from a fixed palette of SYSTEM colours (blue, orange, green, purple, pink, teal, indigo, brown, mint, cyan, red, yellow). The palette follows SelectionStyle’s rule that no RGB appears in the drawing code, so each colour adapts to Light Mode, Dark Mode and Increase Contrast by itself.

Which colour: ruled 2026-10-04, in “Box colour and the segment hierarchy” at the end of these behaviours (source.editor.colour.*). The earlier rules, a hue keyed by a hash of the region’s id and lines alternating two tints, are replaced by it. Colour by TYPE (main text, marginalia, running head) is not the default: the ruling makes colour mean the region.

A colour of its own: the Inspector offers a Colour setting on a region, one of the palette’s names. It is stored on the segment by segment.update, audited, with ⌘Z. The stored value is the palette NAME, never RGB, so it still adapts to the appearance and an export can map it. A set colour replaces the region’s ordered hue for that region and its children.

Selection and hover keep the system’s selection look (SelectionStyle), never a region’s colour (ruled 2026-08-31: “on the page, selection is selection”).

Built 2026-09-28 (d046a7a73), image pages: - the palette (SelectionStyle.regionPalette); - children taking their region’s colour (RegionColours.tones).

Pinned by RegionColoursTests. Still PARTIAL: - the Inspector’s Colour setting, which needs a colour field on segment.update (engine); - the ruled colour rule below is built (2026-10-04: the hash key and the two-tint alternation are gone), not yet seen in the app; see source.editor.colour.*.

  • source.editor.selection-like-preview — [PARTIAL] (#5215) Selection as Preview.app draws it.

A selected segment draws an accent-coloured dashed marquee around its box. In Edit Segments it gets eight handles, at the corners and the edge midpoints: - dragging a handle RESIZES the box from that edge or corner; - dragging inside the marquee MOVES it.

A polygon keeps its point and side handles (reshape) AND gets the eight-handle frame around them, and dragging the frame scales every point. Unselected segments stay thin (thin-by-default).

Each gesture is ONE segment.update carrying the version read, with ⌘Z by its audit id. A resize or move sends all the anchor’s shapes (the rule in reshape), scaled or shifted together.

Built 2026-09-28, image pages: - in Edit Segments the selection is a dashed accent marquee with eight handles, and a shape gets the frame’s handles too; - a handle’s drag resizes (BoxResize), live, and its release is ONE move to the new box through the existing move path (SegmentEdit.move, segment.update with its version and ⌘Z); - that move now carries every point from the old box to the new (SegmentEdit.mapped), shifted when only the place changes and scaled when the size does, so a polygon scales with its frame; - drag-inside-to-move was already built.

Pinned by BoxResizeTests. Still PARTIAL: the PDF page, and a run on screen.

  • source.editor.labels-in-their-direction — [PARTIAL] (#5199) a label drawn for a segment – the hover label on the image, the Segments pane’s rows and strip and grid captions – is laid out in the segment’s RESOLVED direction, the engine’s cascade as the page text serves it per block, never guessed from its characters. ttb is a column beside the line; rtl runs right to left; every label is a Unicode isolate, so a mixed line resolves on its own and never reorders its neighbours. Built 2026-09-28: the page’s line directions load with its segments (SegmentStore, from GET /api/segments/document/{id}/text). A word takes its line’s direction. The hover label stacks a column’s graphemes beside it. Rows and captions isolate the words in the line’s direction; they stay one row, so a vertical line’s caption is an isolate as written. Pinned on the engine’s recorded answers for the real Chinese-vertical and Syriac pages (SegmentLabelTests, recorded by test_reader_directions.py::test_the_page_text_s_directions_are_recorded_for_the_app_s_labels). Still PARTIAL:
  • the text fitted into boxes on the image: the Mac image page’s InlineWords now follows the line’s direction (inline-text-in-its-direction, #5411); the SwiftUI layer’s InlineWordText (the PDF page and iOS) still does not;
  • Mongolian, whose rotated letters want the text turned, not stacked;
  • the Inspector’s own direction for Han, which waits on the resolve route measuring the page’s lines (#5202, engine).
  • source.editor.inline-text-in-its-direction — [PARTIAL] (#5411) with Show Text Inline on, each line’s reading is set INSIDE its own box in the line’s resolved direction – the same direction the hover label and the text preview use (SegmentStore’s line directions from the page text, labels-in-their-direction), never a second guess from the characters. A ttb line is a column of upright graphemes read top to bottom, centred in its box; the columns read right to left because each sits in its own box. An rtl line is set from the box’s right edge. Found on the chinese-vertical page, where every column’s reading was one horizontal string. Built 2026-10-04, Mac image page (InlineWords.layout, the direction carried on DocumentOverlay.Box). Pinned by PreviewVerticalPageTests. Still PARTIAL: the maintainer’s look on the chinese-vertical page; the PDF page and iOS (InlineWordText).
  • source.editor.inline-text-fits-its-box — [PARTIAL] (#5411) an inline reading is scaled to fit its box in BOTH axes – the box’s height and width – and never runs past it, at every zoom. A region under lines sets no text of its own: its words are its lines’, and its reading (every line joined) was being drawn at the region’s size, a page-tall list that overflowed the page at 21% and 47% zoom (a regression from regions-under-lines). A region on a page without lines still sets its reading. Built 2026-10-04, Mac image page (InlineWords.fittedSize fits both axes; DocumentOverlay.setsTextInline). Pinned by PreviewVerticalPageTests. Still PARTIAL: the look on screen.
  • source.editor.hover-picks-the-line — [PARTIAL] (#5411) resting the pointer on a line highlights THAT line (the hover wash and outline of thin-by-default), and the hover label names it: the smallest box under the pointer wins, as a click does. The SwiftUI hover label took the FIRST box containing the pointer, which on a page with regions and lines is the region around every line. Built 2026-10-04: one rule, DocumentOverlay.smallestContaining, used by the drawn hover (DocumentOverlayView) and the hover label (OCRGeometryOverlay); the drawn hover’s tracking area is installed when the view joins its window, not only on the first geometry change. Pinned by PreviewVerticalPageTests. Still PARTIAL: not reproduced on screen by the builder of this slice – whether the overlay’s tracking area was the whole of the missing hover needs the maintainer’s look.
  • source.editor.selection-shared — [PARTIAL] (#4941) selecting a segment in the Source view, Reader or Inspector selects it in the others. Two of the three pairs exist, by two different mechanisms (read on disk 2026-09-27), and the mechanisms are the finding rather than the coverage. Source view ↔ Inspector: one shared object, RegionSelection.shared — written and read by both, plus FocusedRegionSelection.shared for region NODES, whose own comment explains the shape: a process-wide instance rather than environment plumbing, because writer and reader live in different view subtrees. Reader → Source view: a notification, .readerTextSelection, carrying character offsets; the preview turns them into the word boxes whose spans intersect. Not a segment selection — a text range that resolves to boxes. Source view → Reader: the seam accepts a passage landing and PageContentPane+SourceHighlight names “a linked region” as one source of it, but no region-selection code posts one: the only posters are the reader’s own text selections. So clicking a box does not move the reader. What this means for the editor. A segment selection shared three ways cannot be built on either mechanism as it stands: RegionSelection holds INDICES into one artifact’s box list — a position used as an identity, the defect this slice has now met three times — and the reader’s link is character offsets. The editor needs a selection of segment IDS, which is a fourth thing. Built 2026-09-27 as SegmentSelection (Models/SegmentSelection.swift, 11 tests, SegmentSelectionTests): ids in pick order, the document they belong to, the pass they were read from, and the VERSION each had when it was selected — which is what lets a verb send expected_version without re-reading and lets the engine refuse a stale edit instead of overwriting somebody’s work. The rules it enforces, each because a verb depends on it: a selection never spans documents and a refused cross-document add says so by returning false (a shift-click that appears to do nothing is a bug report; one that silently moved the selection to another page is worse); emptying it clears the document and pass, or a verb’s “is this the page on screen?” check would pass on an empty selection; prune(toLive:) drops ids a change event removed and reports them, because keeping them sends the engine ids it refuses one at a time while dropping them silently makes a Delete act on fewer lines than are highlighted; and expectedVersions is all or nothing, since segment.delete takes a version per id and a partial map would delete what it knows and refuse the rest — half a Delete, worse than none. Not yet wired to a view, and that is the next step rather than a claim: what remains for this behaviour is the three surfaces writing to it.
  • source.editor.highlight-on-its-box — [BROKEN] (→ #5020) a selected segment’s highlight is drawn on the box that was clicked, in the pane where it was clicked, at that pane’s own zoom and scroll. Broken: the maintainer saw it land below and right of the clicked box, and in the other Preview pane too. Diagnosed 2026-09-27 (no fix yet): selection is RegionSelection.shared, one app-wide object holding box INDICES, and an index means “position in the box list this pane happens to hold”. Two panes can hold different lists for one artifact (the artifact’s order and the segment seam’s, or a list from before an edit and one from after), so an index picked in one names a different box in the other, typically the next in reading order. Every pane draws the one selection, so it leaks into panes nobody clicked. Owed: selection per pane, followed by the Inspector and the Reader for the focused pane, and named by identity rather than position. Ruled 2026-09-27: each Source-view pane owns its selection; the Inspector and the Reader follow the FOCUSED pane. This applies two standing rulings: panes are not linked unless a person connects them (2026-09-19), and “visible surface, always” (2026-08-23), under which a verb acts on the selection of the surface you can see and focus decides which. Two Previews of one page therefore show only the clicked pane’s highlight. Mirroring comes with CONNECTED panes, by then naming boxes by identity so it lands on the right box. Per-pane selection is built (the canvas owns a RegionSelection; WindowState.focusedRegionSelection with focus, offer and release rules; pinned by fichero/Tests/Unit/general/Models/RegionSelectionPerPaneTests.swift). Selection by identity is built too (3af7a003c): each selected box keeps its bbox, text and level beside its index, and every reader resolves it in its own list – a box a list no longer holds is dropped, never replaced by the box at its old index (the same test file). Still owed before this is [OK]: the maintainer’s check on screen at two zooms, and the drawing half (overlay-moves-with-the-image).
  • source.editor.overlay-moves-with-the-image — [BROKEN] (→ #5142) segment overlays move with the image, frame for frame, while it is scrolled and zoomed, on a page as dense as the Cherokee Phoenix p. 2. Broken: they lag, then catch up. Diagnosed 2026-09-27 (no fix yet): the image is transformed by AppKit’s scroll view in the gesture’s own pass; the overlays are SwiftUI views laid out from a geometry measured on boundsDidChange and handed over through a Task hop, so they are always at least one hop behind. Each tick then re-maps and re-strokes every box (4,525 on that page). Owed: the boxes drawn inside the scroll view’s document view, in image coordinates, so one transform moves pixels and boxes together and only the visible boxes are drawn (source.editor.one-overlay).
  • source.editor.edits-are-actions — [PARTIAL] (#4941) every edit is one audited, reversible engine action; the editor updates only the changed segments. Both halves hold for the edit path that exists today (audited 2026-09-27); what is owed is the editor itself, whose own verbs will have to keep them. One audited, reversible action: a region edit reaches segment.convert_and_edit (undoable=True, invert=_invert_convert, domains segment/artifact/document), and the first edit CONVERTS and edits in the same step — pinned by TestTheFirstEditConvertsAndEdits.test_one_action_and_one_undo_step, which asserts exactly one audit row for an edit that does two things, and by .test_the_block_is_byte_equal_through_edit_and_undo, which is the stronger claim: the stored block is byte-equal before the edit, after it, and after the undo, so reversing leaves no residue in the record the boxes came from. Only the changed segments: the undo restores the moved row by a NEW VERSION rather than erasing one and keeps the conversion (.test_the_geometry_goes_back_and_the_rows_stay), and on the app side a change event patches the rows it names instead of re-reading the page (SegmentStoreTests.testAnEventNamingHeldSegmentsPatchesThoseAndDoesNotReload). The verbs re-render from the response’s own fresh geometry rather than re-fetching, which is the same rule from the other end.
  • source.editor.redo-works — [PARTIAL] (#4957) after undoing a segment edit, Redo (⇧⌘Z) does it again; redo is worked out afresh as the undo of the undo, so it succeeds although the segment’s version has moved on; it is refused only if something else has changed the segment since. Doing, undoing, redoing and undoing again, any number of times, ends where the first undo ended, and nothing is left over under a new id. A segment or pass that comes back on redo comes back under its own id. The version half is COMMITTED and pinned (audited 2026-09-27; this entry said “in the worktree, not yet committed”, which was true when written): redo is worked out afresh so it survives the row having bumped twice — TestSegmentUpdateUndoRedo.test_redo_reapplies_update_without_a_stale_expected_version — do/undo/redo/undo round-trips to where the first undo ended (.test_undo_redo_undo_round_trips), and a genuinely stale redo is still refused when a third writer touched the row in between (.test_redo_still_refused_when_another_writer_bumped_the_version_since). Before #4957 redo replayed the original expected_version and always 409’d, because versions only go up. (Reviewed twice, 2026-09-20. Still owed under #4957: merge, split and carry, and their inverses, take no version number, so a redo is not refused when someone else changed a member in between, and undoing a split deletes its parts outright even if someone else has worked on one; the parts of a split, the copies of a carry and a proposed match get new ids on each redo.) The rule itself, that a step is reversed through its own inverse when it has one, belongs to the shared undo route, not to segments; the safety set (branch spec/undo-trash, not merged) proposes it for every action, and this set agrees. (Today a redo of a segment edit is refused as stale: the shared undo route replays the original request. The editor cannot ship without this.)
  • source.editor.system-undo — [PARTIAL] (#4941) ⌘Z and ⇧⌘Z undo and redo editor actions through the action pass. (Rule (i) flags test_artifact_regions_edit.py as mentioning this id: that file pins the ENGINE half added for it — the route now names its audit row — and asserts nothing about ⌘Z, which is what keeps the tag [GAP]. The mention is the note above, not coverage.) Genuinely absent, and the blocker is one missing value rather than the wiring (read on disk 2026-09-27). The engine half is complete: POST /api/actions/audit/{audit_id}/undo inverts any audited action, and redo is the undo of the undo (pinned in test_action_undo.py). The app knows how to do its half too — registerUndo is used by the sidebar, the library canvas and the workflow canvas, so there is a pattern to copy rather than a design to invent. What was missing between them was one value: the region-edit route answered an ArtifactResponse and never said which audit row it wrote, so the app had no audit_id to hand ⌘Z — and reading the newest audit row instead would be a race against any other writer, which on a shared library is one person undoing another’s edit. Fixed 2026-09-27: PUT /api/artifacts/{id}/regions answers ArtifactRegionsEditResponse, a SUPERSET of ArtifactResponse so every existing caller decodes it unchanged, carrying audit_id. Pinned by TestTheEditNamesTheActionItWrote.test_the_response_carries_the_audit_id — which resolves the id to its row and checks the action name, rather than only checking a field is present — and .test_it_is_still_the_artifact_response_every_caller_decodes. The app half is written (2026-09-27), not yet compiled. ActionUndo bridges an audited action to the system UndoManager, and move, delete and combine in the Source view register it with the audit id their own response carries. The one subtlety is UndoManager’s bookkeeping: a registration made while it is undoing is filed as a REDO, and the engine call is async, so its answer — the inverse row’s own id, which the redo must invert — arrives after the handler has returned. Registering then would file a new undo and leave ⇧⌘Z doing nothing. So the redo is registered synchronously inside the handler, holding a box the engine’s answer fills; a redo pressed before the answer arrives does nothing rather than inverting a guessed row. Pinned by ActionUndoTests against a REAL UndoManager (ActionUndoTests.redoIsRegisteredDuringTheUndo, .redoInvertsTheUndosOwnRow, .theChainKeepsGoing). Stays [PARTIAL] until it has been compiled and seen working, and three limits are stated: a marquee PROMOTION is one engine action per marquee, so it registers no ⌘Z rather than an “Undo Promote” that undoes one region of three. The Inspector’s Combine is now wired (#5115, ruled) through the same RegionEditResult.registerUndo the Source view uses. The redraw after ⌘Z was broken, and an earlier line here said it “rides the change stream”. Checked over HTTP against the real engine: the edit emits segment.converted, ⌘Z emits segment.updated, and ⇧⌘Z re-applies the move. The engine half is pinned by fichero-server/tests/unit/api/test_region_edit_undo_redo_over_http.py::test_undo_restores_and_redo_reapplies_by_the_ids_each_step_answers. The event did reach SegmentStore, which patched its row. But the image Preview, the PDF page and the Inspector’s region list each COPY what they draw, and they re-read only on artifact events and their own ids. So the page kept drawing the edit that had just been undone. SegmentStore.revisions is now a per-document generation, bumped by a patch or a forced re-read but not by a first load, and it is in all three task keys. Pinned by SegmentStoreTests.testAPatchBumpsThatDocumentsRevisionAndNoOther and SegmentStoreTests.testAForcedReloadBumpsTheRevisionAndAPatchOfAnUnheldIdDoesNot. That a view actually redraws is still the click-around leg of the test matrix.
  • source.editor.agent-parity — [PARTIAL] (#4941) every edit the editor can make can be made over MCP and the command line through the same actions. The command line: yes, and by construction rather than by design (audited 2026-09-27). fichero-cli’s openapi_surface_generated.py generates a command for every endpoint in the contract, so all 22 segment operations are reachable — segments_update_put, segments_split_post, segments_merge_post, segments_delete_post, segments_choose_reading_post, segments_carry_across_match_post and the rest. Worth stating plainly because it is the same property that made check_endpoint_usage’s CLI axis meaningless (#5105): the CLI reaches everything whether or not anybody designed a command for it, so this half of the behaviour cannot fail and cannot be evidence of anything either. MCP: reads only until 2026-09-27, six write tools now. fichero_segment_update, _split, _merge, _delete, _undelete and _choose_reading, each one call to the route that already owns the audited action — no second write path. The refusals travel with them, which is the whole design constraint: update requires expected_version and delete a version per id, so a row somebody edited since is a 409 the agent must read and act on rather than something to paper over by re-fetching the version first. Pinned by test_mcp_server.py::TestSegmentWriteToolsCarryTheRoutesRefusals, including that the update sends only the fields the caller set (blanking a segment’s language because somebody moved its box is the obvious way to get this wrong) and that a 409 propagates rather than returning a dict that reads like success. choose_reading is offered although a machine acting as itself is refused with 403: the tool exists so the refusal is REPORTABLE instead of invisible for want of a surface offering it. Still [PARTIAL], because the behaviour is “every edit the editor can make” and the editor is unbuilt: reshape, cut, join-group, set-kind and the rest have no verb yet on any surface, so parity is currently a claim about six verbs rather than about an editor.
  • source.editor.two-switches — [GAP] (#4941) the editor’s view switches are LAYERS (ruled 2026-09-27, Q2): the image on or off and the overlays on or off, overlays without the image included, each with defaults tied to the workspace. It is no longer a hard count of two. Show Order and Show Links are among the overlay layers. Counted on disk 2026-09-27, eleven stored settings exist in ZoomableImagePreviewMac: magnifierEnabled, loupeEnabled, loupeMagnification, loupeSize, panelMagnification, panelHeight, magnifierLocked, loupeLocked, annotationsEnabled, regionsEnabled, inlineTextEnabled. Of these, annotationsEnabled and regionsEnabled become overlay layers; the loupe and magnifier are viewing aids, not layers. Nothing yet turns the image itself off, and no setting carries workspace defaults.
  • source.editor.draw-shapes — [PARTIAL] (#4941) box, polygon, point, line and baseline can be drawn. Built 2026-09-28 (image pages, segment passes), in the ONE Shape tool: its kind menu, shown in Edit Segments, picks Box (a drag, as before), Polygon (click points; a click on the first point, or a double-click, closes it) or Baseline (click points; a double-click finishes). A finished polygon is segment.create of a REGION anchored by its outline and the rect it bounds; a finished baseline is a LINE whose anchor is the baseline as an open path – no outline invented – with that baseline; both on the shown pass, naming its picture, ⌘Z by audit id. A drawn line with a flat baseline is drawn and clicked by its shapes’ bounds (SegmentShapes.displayBox). Pinned: the engine takes the app’s exact creates and the page stays drawable, undo removing them (test_imported_page_draws_its_boxes.py::test_the_shape_tool_s_polygon_and_baseline_become_segments_the_canvas_still_draws); the app closes, refuses too few points and sends those calls (ImportedPageDrawsItsBoxesTests.testTheShapeToolsPolygonAndBaselineCreateSegmentsWithUndo). Escape abandons a drawing in progress and does nothing else; the next Escape clears as it always did (WindowState.abandonDrawing, ShapeDrawingEscapeTests). A drawn line lands in its region: it is created as a line of the region holding MORE THAN HALF of its bounds (the most, then the smallest; SegmentShapes.containingRegion), so the Inspector’s path reads Region › Line; none holds it, it stays at page level and the path says only Line – no guessing. The same create places it in the as-written order among that region’s lines by its place on the page (#4930: page order, never append): a line drawn between two of the region’s lines goes between them, one drawn below its last line is its last. One ⌘Z removes the line and its entry from the order itself; ⇧⌘Z puts the entry back where it was (test_imported_page_draws_its_boxes.py::test_a_line_drawn_inside_a_region_is_that_region_s_line_in_its_order_and_one_undo_takes_both, Syriac; ImportedPageDrawsItsBoxesTests.testALineDrawnInsideARegionIsCreatedAsThatRegionsLine). Not yet: a point and an open line; pages whose boxes come from an artifact.
  • source.editor.reshape — [PARTIAL] (#4941) points can be dragged, added and removed; shapes moved and nudged. Built 2026-09-28 (image pages, segment passes): the overlay draws a segment AS its shapes – the outline its file drew, an open path, a point, the baseline under the ink – not its box (SegmentShapes.drawn, DocumentOverlayView/ShapeDrawing), with the Mac selection look. In Edit Segments the ONE selected segment shows a square handle per point of its outline and baseline and a round one per side: drag a point, press a side to add one and drag it, ⌥-click to remove one (never below three for an outline, two for a baseline). Each is one segment.update – the polygon with the rect it bounds, or the baseline alone – checked against the version read, ⌘Z by its audit id; an outline rewrite on a segment with extra shapes is refused, since the anchor sent would drop them. Pinned: the engine gets every Syriac PAGE line’s polygon and baseline point for point from the file (lxml) and takes the app’s exact reshapes and two ⌘Z (test_imported_page_draws_its_boxes.py::test_an_imported_page_s_polygons_and_baselines_reach_the_app_as_the_file_drew_them_and_reshape_lands); the app draws them and sends those calls (ImportedPageDrawsItsBoxesTests.testALinesOutlineAndBaselineAreDrawnAsThemselvesAndReshapeSendsTheCheckedUpdate, …testReshapingALinesOutlineSendsTheCheckedUpdateAndUndoes, …testReshapingABaselineSendsItAloneAndTheRefusalsHold). Two successive edits of one segment undo twice (test_segment_undo_twice.py; the engine refused the second ⌘Z before). Also built (2026-09-28): the anchor’s extra shapes – an area, an open path, a point – are drawn, get handles and are reshaped the same way (a point moves only: it takes no new points and is never removed); every anchor rewrite now sends back ALL the shapes, the changed one changed, so none is dropped – the outline reshape and Move no longer refuse a segment with extra shapes, and Move shifts them with the box. Nudge: the point last pressed stays selected (its handle filled); the arrow keys move it one image pixel, ten with ⇧, each one segment.update with ⌘Z, and without a selected point the arrows keep paging. Pinned: the engine takes the app’s exact path-and-point rewrite and undoes it (test_imported_page_draws_its_boxes.py::test_reshaping_an_anchors_path_and_point_shapes_is_the_app_s_exact_update); the app’s handles, limits, pixel nudge and request (ImportedPageDrawsItsBoxesTests.testReshapingAnAnchorsPathAndPointAndNudgingByAPixel; Move with shapes, SegmentEditTests.moveShiftsExtraShapesToo). PDF pages draw shapes too (2026-09-28): the PDF renderer’s annotations follow the same shapes – the outline and any area as closed ink, a path and the baseline as open ink (the baseline heavier), a point as a dot, a segment with no reading dashed – placed through the page’s crop box and rotation (PDFShapeAnnotations, PDFRegionGeometry.pagePoint; the engine test imports a PAGE file onto the corpus’s real PDF page, the e2e draws its lines on that page: test_a_pdf_page_given_a_page_file_s_lines_reaches_the_app_with_their_outlines_and_baselines, ImportedPageDrawsItsBoxesTests.testAPDFPageDrawsALinesOutlineAndBaselineAsThemselves). PDF pages reshape too (Mac, 2026-09-28): Edit Segments now shows on a PDF page’s head; a click selects the smallest segment under it, its handles are drawn (squares on points, circles on sides), and a drag from a handle moves or adds a point, ⌥-click removes one – read back into the stored display space through the page’s crop box and rotation (PDFRegionGeometry.normalizedPoint, the inverse of the drawing’s pagePoint) and decided by the image’s own rule (ReshapeDrag.press), so the same SegmentShapes.reshape is sent, ⌘Z by its audit id (ImportedPageDrawsItsBoxesTests.testReshapingALinesBaselineOnAPDFPageSendsTheImagesUpdateAndUndoes, the recorded Syriac line on the corpus’s real PDF page). A PDF page’s selection is the ONE selection (#5155’s ruling): a click on a PDF page writes the line into the page’s selection in the shown pass’s scope, as an image does, so the Inspector inspects it; the Reader’s caret line (or an Inspector crumb) written the other way is the box the PDF page then outlines, or gives handles to in Edit Segments (PDFSegmentEditing.select/.selected; ImportedPageDrawsItsBoxesTests.testAPDFPagesSelectionIsTheOneSelectionTheInspectorAndReaderShare). Not yet: on a PDF page, a click selects only in Edit Segments (outside it a click is PDFKit’s text selection), nudging with the arrow keys, Move, Delete, Join and drawing new shapes – and so a PDF page’s head HIDES the Shape kind menu (Polygon, Baseline), Delete and Join while editing segments, rather than offer controls that do nothing (SegmentVerbs.pdfPage; ImportedPageDrawsItsBoxesTests.testAPDFPagesHeadOffersOnlyTheSegmentVerbsThatAct); iOS; a pass with an artifact behind it (its boxes carry no shapes); a held arrow key is one edit per press, not coalesced.
  • source.editor.propose-shape — [GAP] (#4941) a click asks the engine to propose a shape, which can then be adjusted.
  • source.editor.cut — [GAP] (#4941) a scissors stroke splits one or many lines at once.
  • source.editor.join-group — [PARTIAL] (#5159, #4941) selected segments can be merged; lines grouped into a region and ungrouped. Join is ⌘J (ruled 2026-09-28): the head’s Join, offered while editing with two or more selected (PreviewMarkupToolsRow, was ⌘⌥C). ⌘J was free – the only j chord is ⌃⌘J, Show Side Preview – and MenuShortcutUniquenessTests now reads the markup row’s chords too (it scanned the row’s old file only). The join half has its plan (2026-09-27); grouping lines into a region does not. SegmentEditCommand.mergePlan turns a SegmentSelection into one segment.merge request and refuses three ways — fewer than two selected, a selection left over from another page, a participant with no known version (merge takes one for EVERY id, the kept one included) — pinned by SegmentMergePlanTests. The first segment picked survives, stated because it is a decision: merged-away ids keep forwarding, so old citations resolve, but the survivor’s id is what new citations use and the first click is the only choice the person visibly made. Pass membership is left to the engine’s SegmentPassMismatchError rather than copied here. No menu item or key calls it yet, and “lines grouped into a region” needs a create-parent verb that does not exist on any surface.
  • source.editor.set-kind — [PARTIAL] (#4941) the selection’s kind (and furniture or text) can be set. The plan is built (2026-09-27), the verb is not. SegmentEditCommand.plan turns an attribute and a SegmentSelection into the segment.update requests to send, and refuses three ways — nothing selected, a selection left over from another page, a selected segment with no known version — each tested in SegmentEditCommandTests (e.g. SegmentEditCommandTests.selectionOnAnotherPageIsRefused). Values pass through unchecked on purpose: the engine owns the vocabulary and refuses what it does not know, and a second copy on the client is the one that drifts. No menu item or key calls it yet. And the engine gap the plan exposed is fixed (2026-09-27). segment.update takes ONE segment and the only bulk route created, so a selection-wide edit was one audited action PER LINE — and one undo step per line. Now PATCH /api/segments → segment.update_many: one audit row for N rows, all or nothing (a stale line refuses the whole edit, as segment.delete refuses its list), the cascade’s facts through the same writer segment.update uses so a bulk edit is no way around assert_known_script, and one undo restoring every line through its inverse segment.restore_versions. Pinned by test_segment_update_many.py (10 tests, including TestOneEditIsOneAction.test_one_undo_restores_every_line and TestAllOrNothing.test_one_stale_line_refuses_the_whole_edit). Redo goes through the inverse row’s OWN invert, because the generic replay freshens only a top-level expected_version and a bulk edit’s versions live inside updates[] — the redo test failed until that was set.
  • source.editor.set-direction — [PARTIAL] (#4941) the selection’s direction can be set, and a line reversed. The plan is built (2026-09-27), the verb is not. SegmentEditCommand.plan turns an attribute and a SegmentSelection into the segment.update requests to send, and refuses three ways — nothing selected, a selection left over from another page, a selected segment with no known version — each tested in SegmentEditCommandTests (e.g. SegmentEditCommandTests.selectionOnAnotherPageIsRefused). Values pass through unchecked on purpose: the engine owns the vocabulary and refuses what it does not know, and a second copy on the client is the one that drifts. No menu item or key calls it yet. And the engine gap the plan exposed is fixed (2026-09-27). segment.update takes ONE segment and the only bulk route created, so a selection-wide edit was one audited action PER LINE — and one undo step per line. Now PATCH /api/segments → segment.update_many: one audit row for N rows, all or nothing (a stale line refuses the whole edit, as segment.delete refuses its list), the cascade’s facts through the same writer segment.update uses so a bulk edit is no way around assert_known_script, and one undo restoring every line through its inverse segment.restore_versions. Pinned by test_segment_update_many.py (10 tests, including TestOneEditIsOneAction.test_one_undo_restores_every_line and TestAllOrNothing.test_one_stale_line_refuses_the_whole_edit). Redo goes through the inverse row’s OWN invert, because the generic replay freshens only a top-level expected_version and a bulk edit’s versions live inside updates[] — the redo test failed until that was set.
  • source.editor.set-language-script — [PARTIAL] (#4941) the selection’s language and script can be set. The plan is built (2026-09-27), the verb is not. SegmentEditCommand.plan turns an attribute and a SegmentSelection into the segment.update requests to send, and refuses three ways — nothing selected, a selection left over from another page, a selected segment with no known version — each tested in SegmentEditCommandTests (e.g. SegmentEditCommandTests.selectionOnAnotherPageIsRefused). Values pass through unchecked on purpose: the engine owns the vocabulary and refuses what it does not know, and a second copy on the client is the one that drifts. No menu item or key calls it yet. And the engine gap the plan exposed is fixed (2026-09-27). segment.update takes ONE segment and the only bulk route created, so a selection-wide edit was one audited action PER LINE — and one undo step per line. Now PATCH /api/segments → segment.update_many: one audit row for N rows, all or nothing (a stale line refuses the whole edit, as segment.delete refuses its list), the cascade’s facts through the same writer segment.update uses so a bulk edit is no way around assert_known_script, and one undo restoring every line through its inverse segment.restore_versions. Pinned by test_segment_update_many.py (10 tests, including TestOneEditIsOneAction.test_one_undo_restores_every_line and TestAllOrNothing.test_one_stale_line_refuses_the_whole_edit). Redo goes through the inverse row’s OWN invert, because the generic replay freshens only a top-level expected_version and a bulk edit’s versions live inside updates[] — the redo test failed until that was set.
  • source.editor.set-hand-campaign — [GAP] (#4941) the selection’s hand and campaign can be set.
  • source.editor.reorder — [PARTIAL] (#4941) a named reading order can be edited by dragging in a list or clicking segments in turn. The list half has its translation (2026-09-27); the on-page half does not. A drag in a list is ONE reading_order.place, which already moves an existing entry as one row and one action. ReadingOrderMove.place turns a target index into the after_entry_id it needs — measured in the list WITH THE MOVED ENTRY TAKEN OUT, which is the off-by-one that lands a downward move one place too far if missed — and refuses a drop onto its own place (an audited action that changed nothing) and a segment not in the level (a move must not quietly become an insert). Pinned by ReadingOrderMoveTests in both directions, including the conversion from SwiftUI’s onMove offsets, which count the list as drawn. No list view calls it yet, and reordering by drawing on the page needs the editor’s overlay.
  • source.editor.draw-link — [GAP] (#4941) dragging from one segment to another makes a typed link.
  • source.editor.match-across-passes — [GAP] (#4941) a segment in one pass can be matched to one in another.
  • source.editor.transcribe-by-line — [GAP] (#4941) with a line selected, the Reader shows its picture above its reading; Return selects the next line in the order and the Source view follows. (The Reader’s planned in-place transcription editing, → #4375, is this same editor: one place to type a reading.)
  • source.editor.marks — [GAP] (#4941) the selection can be noted, highlighted, checked and tagged.
  • source.editor.control-points — [GAP] (#4941) control points can be placed and given coordinates.
  • source.editor.keyboard-complete — [GAP] (#4941) every command has a menu item and can be done from the keyboard.
  • source.editor.pencil-draws — [GAP] (#4941) on the iPad the Pencil draws shapes, baselines and cuts.
  • source.editor.pencil-traces-strokes — [GAP] (#4941) a Pencil trace is kept as stroke segments with position, time, pressure and tilt.
  • source.editor.smooth-when-dense — [GAP] (#4940) zoom, pan, hit-test and drag hold sixty frames a second with twenty thousand shapes on the oldest supported iPhone (the ruled target; the trial settles how to draw, and may come back and say the target is wrong).
  • source.editor.level-of-detail — [GAP] (#4940) finer levels appear as you zoom in.

The trial that settles it, and what makes its numbers checkable (slice 12, #4940)

The method is built; the measurements are not. scripts/perf_trial.py is the verdict the section above writes down. It takes one recorded run and a committed baseline (scripts/perf_trial_baseline.json) and says PASS, FAIL, INCONCLUSIVE, VOID or REFUSED, with exit codes 0, 1 or 2. It takes no measurements itself. The app’s frame harness (Release build, on the oldest supported iPhone and the Mac) and the engine latency test produce results in its format. Two thresholds the spec had left open were ruled by the maintainer on 2026-09-27, as the script had them. The edit gate, like the frame gate, is decided by the WORST run. Memory “grows with the count”, and FAILS, when the peak at 20,000 shapes is 2.5 times the peak at 5,000 or more. Until a run on each machine is recorded, these behaviours are PARTIAL.

The trial’s first finding (2026-09-27), before any editor UI. The engine’s share of the edit gate is measured by fichero-server/tests/perf/test_segment_edit_undo_perf.py. It imports the 20,000-shape page through format.import, then moves one word and undoes it through the app’s own routes, one cold run and five measured runs, recorded as engine-edit-undo (build “engine”, the same 100 ms budget, a necessary condition and not the gate). The edit took 26 ms and its undo took about 14 s. Traced by capturing the call stack on every call: the undo of a move is a segment.restore_version, which the page-text cache (#5077) listed as a membership change unconditionally. So one undo re-derived the whole page, with an N+1 of 20,017 readings_of_segment calls. The edit itself was already exempt for geometry. A restore now records text_relevant (whether it puts back a different parent, furniture flag, kind or pass), and the cache skips only an explicit false. After: about 48 ms per edit and undo, measured while other lanes loaded the machine, and so recorded VOID by the trial’s own rule. Pinned by fichero-server/tests/unit/api/test_page_content_is_a_cache.py::TestUndoingAMoveIsAsCheapAsTheMove::test_undoing_a_move_does_not_derive_the_page (which fails on the code before the fix) and ::TestUndoingAMoveIsAsCheapAsTheMove::test_undoing_a_furniture_change_still_refreshes (the exemption stops where the text can change). The second finding is the derivation itself, which the undos that do change the text still pay. Deriving the 20,000-segment page took about 14.5 s, with 20,017 single-row lookups: readings_of_segment re-walked forwarding for live rows already in hand, and ran one representation query and one kinds query per row. The page’s readings are now gathered in one batch (_readings_for_live_rows). About 1.8 s now, again measured under load. The third finding: 2.9 s of what remained was loading every word and character only to skip it for having no reading, and the other 20,000-row load was the working-pass check asking “is there a human segment?” by fetching them all. The default order now loads only the rows that can carry text (a stored reading, or a box in the artifact a pass was converted from, found by one SQL scan), the working-pass check counts instead of loading, and the page’s reading choices are fetched once instead of once per line. About 80 to 100 ms warm for the whole 20,000-shape page, under load, and so inside the edit budget without per-action bookkeeping (a second copy of ordering, separators and bidirectional joining that would have to stay in step with the first). Pinned by fichero-server/tests/unit/api/test_page_content_is_a_cache.py::TestTheTextIsDerivedFromTheLinesThatCarryIt::test_furniture_delete_merge_and_split_all_derive_the_same_text (the same text and spans as deriving from the whole pass, after each kind of text-changing action), ::TestTheTextIsDerivedFromTheLinesThatCarryIt::test_a_converted_page_with_provisional_readings_derives_the_same_text and ::TestTheTextIsDerivedFromTheLinesThatCarryIt::test_it_never_loads_the_whole_pass (fails on the code before). A named reading order still loads every row, because it must say why each id it names is missing. Pinned by fichero-server/tests/unit/api/test_page_content_is_a_cache.py::TestAPagesTextIsDerivedInOnePass::test_the_batch_gives_exactly_what_each_row_would (the same answer as the per-row path, stored and provisional readings both) and ::TestAPagesTextIsDerivedInOnePass::test_deriving_a_page_looks_no_row_up_one_at_a_time (fails on the code before). The gate was met on the fixture and NOT on real pages (found 2026-09-27). The generated 20,000-shape fixture (scripts/perf_fixture.py) carries no text at all: imported through the library it has 111 regions, 474 lines, 3,172 words and 16,260 characters and 0 readings, so its “about 80 to 100 ms” derivation read nothing. It measured the pass ranking and the queries of an empty page. A real page takes the path the fixture never did: the Cherokee Phoenix p. 2 (15 regions, 600 lines, 3,910 words, a reading on every word) derived in 509 to 1171 ms warm, 1.31 s under the profiler, at load 20 to 42, so VOID by the trial’s rule but far outside the budget. The profile: about 83% is row hydration, every reading is loaded and hydrated TWICE (_text_bearing_rows, then _readings_for_live_rows), and each segment’s anchor is re-validated on read. Owed, and owned by the bugs lane: the fixture gets text whose lengths follow its measured chars_per_word, through the trial’s baseline update; readings load once; then a quiet-machine measurement of both pages. - source.perf.worst-frame-not-mean — [PARTIAL] (#4940; the verdict is built and pinned by fichero-server/tests/unit/scripts/test_perf_trial.py::TestWorstFrameNotMean::test_one_dropped_frame_fails_a_run_whose_mean_is_fine. No measurement has been recorded yet) the frame gate is on the worst frame in a run against a 16.7 ms budget, never the mean, because a mean hides the dropped frame a person feels. - source.perf.five-runs-median — [PARTIAL] (#4940; the verdict is built and pinned by fichero-server/tests/unit/scripts/test_perf_trial.py::TestFiveRunsMedian::test_runs_that_disagree_are_inconclusive_not_the_best_of_five, ::TestFiveRunsMedian::test_the_cold_run_is_discarded. No measurement has been recorded yet) a measurement is the median of five runs after a discarded cold one, reported with the worst frame; runs that disagree by more than a tenth of their median are inconclusive, and the best of the five is never the answer. - source.perf.names-its-machine — [PARTIAL] (#4940; the verdict is built and pinned by fichero-server/tests/unit/scripts/test_perf_trial.py::TestNamesItsMachine::test_a_debug_build_is_refused, ::TestNamesItsMachine::test_a_result_with_no_machine_is_refused. No measurement has been recorded yet) every recorded number carries the device model, the OS version, the build configuration and the fixture it was measured on; a Release build, never Debug. - source.perf.declared-fixture — [OK] (→ #4940) the trial’s page is one committed fixture whose shape count, nesting and size distribution come from a named real page, and whose own file says it is generated. scripts/perf_fixture.py generates it deterministically from scripts/perf_trial_fixture.json, which holds the SAMPLES measured on two named real pages (OCR-D’s Aepinus page and the ALTO project’s page): lines per region, words per line, characters per word from each word’s real text, and word width over line height. It is generated because the densest real page here is 576 shapes and none has character-level shapes. What is chosen (equal character widths, columns proportioned to the page, one uniform scale) is declared as chosen. 20,000 requested gives 20,076 shapes on four levels of nesting, and it exports as valid PAGE. Pinned by fichero-server/tests/unit/scripts/test_perf_fixture.py::test_the_committed_samples_are_what_the_named_real_pages_measure (the declaration is re-measured, not asserted), ::test_relative_sizes_are_the_real_ones and ::test_two_runs_measure_one_page. - source.perf.void-when-throttled — [PARTIAL] (#4940; the verdict is built and pinned by fichero-server/tests/unit/scripts/test_perf_trial.py::TestVoidWhenThrottled::test_a_throttled_run_is_void_not_failing, ::TestVoidWhenThrottled::test_an_unreadable_thermal_state_is_refused_not_passed. No measurement has been recorded yet) a run taken on a thermally throttled or busy machine is void rather than failing, and a run that cannot read the thermal state records nothing at all rather than recording a pass. - source.perf.baseline-or-fail — [PARTIAL] (#4940; the verdict is built and pinned by fichero-server/tests/unit/scripts/test_perf_trial.py::TestBaselineOrFail::test_a_regression_past_the_noise_band_fails_even_under_the_gate, ::TestBaselineOrFail::test_update_writes_only_a_pass_and_records_the_machine. No measurement has been recorded yet) results are compared with a committed baseline that states its machine, OS, fixture and date; a regression beyond the noise band fails, and an improvement updates the baseline in the commit that earned it. - source.perf.memory-growth — [PARTIAL] (#4940; the verdict is built and pinned by fichero-server/tests/unit/scripts/test_perf_trial.py::TestMemoryGrowth::test_memory_that_grows_with_the_shape_count_fails_whatever_the_figure. No measurement has been recorded yet) peak memory is recorded rather than gated on an invented ceiling, and the trial fails when peak memory scales with a page’s shape count instead of with what is visible. - source.perf.may-say-the-target-is-wrong — [GAP] (#4940) the trial may conclude that the ruled target is the wrong gate and say so with its measurements; it may not pass by lowering the target. - source.editor.voiceover — [GAP] (#4941) each visible segment is an accessibility element with kind, reading and order. - source.editor.one-input-seam — [GAP] (#4941) pointer, touch and Pencil feed one input path; the same editor runs on Mac, iPad and iPhone.

Box colour and the segment hierarchy (ruled 2026-10-04, #5426, #5463)

This section owns what a segment box’s colour means and how a child shows as its parent’s child, on every surface that draws or lists segments: the Preview image and PDF page, the Segments list and strip, and the Reader. layers-on-the-source.md, preview-surface.md and reader-view.md link here rather than restating it.

The ruling (maintainer, 2026-10-04). Colour is defined by the REGION. Each region has its own hue. Inside a region, its lines are shaded as a gradient along the reading order of the working pass, so the order reads at a glance. Nothing is coloured at random. Words are children of lines, as lines are children of regions and letters are children of words, and each must be seen to be its parent’s child. This settles the disagreement between this spec’s region colours (#5200, with an open “by type” question) and layers.colour.pane-chooses-meaning (#5426, the pane chooses, reading order by default): the colour means the region and its shade means the order. No pane switch picks another meaning. Who made a box and how sure it is keep their existing marks (dashed stroke for a model’s unconfirmed or uncertain box, layers.state.drawn), not the colour.

Today (read 2026-10-04). RegionColours.tones finds a segment’s region by walking up to its nearest region ancestor, “else its topmost ancestor, else itself”. A line with no region is therefore keyed by its OWN id, and RegionColours.paletteIndex hashes that id (FNV-1a) into the twelve-colour palette, so each region-less line gets an unrelated colour. On a page where every line is its own region (a Kraken page), every line gets an unrelated colour. Lines then alternate two tints in as-written order (boxIndex), not in a reading order. The image overlay draws this (DocumentOverlayView.drawBoxes via SelectionStyle.regionColour). The SwiftUI canvas (OCRGeometryOverlay) strokes every box in the accent colour. When a page has words, only the words are drawn (OCRGeometry.displayIndexedBoxes), so their lines and regions vanish.

Built 2026-10-04 (the colour, not the hierarchy). RegionColours.tones assigns each region a hue in the order the reading order reaches it, gives region-less segments the page’s one implicit region, and shades a region’s lines from full strength to 0.45 along the order; the hash (paletteIndex) and the two-tint alternation are deleted. ONE colour path (#5467 part b): SelectionStyle.regionColour(tone) colours the image overlay (DocumentOverlayView.drawBoxes), the SwiftUI canvas (OCRGeometryOverlay, its accent stroke deleted), the PDF page (applyOCRBoxes, PDFShapeAnnotations, their teal deleted) and the Inspector’s region rows (ArtifactRegionsSection, whose RegionPalette keyed by box index is deleted; each row takes the tone of the box the Preview draws, by BoxKey). The order is the working pass’s as-written order (boxIndex) on every surface: the Preview holds no ReadingOrderStore, so a named order chosen in the Segments pane does not yet reshade the boxes (a ponytail: in SegmentDisplay.geometry). Artifact geometry, which has no regions, is drawn in the plain accent. A region’s own set colour is not built.

Built 2026-10-05 (the hierarchy, A, #5426). SegmentHierarchy decides how each level is drawn from the one tone: a region at full strength with a faint wash (0.06) and a thin outline, a line at 0.8 of its shade, a word at 0.55 and a letter at 0.4, words and letters as half-width hairlines – so every child is lighter than its parent. OCRGeometry.displayIndexedBoxes now returns every level (the words-only ladder is deleted), and each drawn box carries its parentSegmentId. The image overlay (DocumentOverlayView.drawBoxes), the SwiftUI canvas (OCRGeometryOverlay) and the PDF page (applyOCRBoxes, PDFShapeAnnotations.look) draw it; only the finest level drawn sets its reading inline. A selection lights its children, shows its ancestors at full strength and dims the rest (SegmentHierarchy.emphasis; on the image from the selected boxes, on a PDF page from its one selected box). The Segments list nests (SegmentsPane.outline, SegmentOutlineRow): a region row open on its lines, a line closed on its words, each row with its box’s swatch; the strip and grid carry the swatch. The Reader’s text rules each region’s block of lines down its leading edge (right for RTL, top for a vertical page) in the region’s hue, the app sending the palette NAME (ReaderRegionRules, window.fichero.showRegions, WebKit’s -apple-system-<name>). The image page’s “What to show” menu lists each region’s gradient swatch and says the shade is the reading order. Pinned by SegmentHierarchyTests (through SegmentStore), ImportedPageDrawsItsBoxesTests (the hosted pane lists 4 regions and their 12 lines) and test_reader_region_rules.py. Not built: the Reader’s Lines mode and a highlighted word in the region’s hue; the PDF page’s menu legend; a selection’s lighting on the SwiftUI canvas has no caller passing a selection yet.

The design. - A region’s hue. Regions take palette hues in the order the working pass reads its regions: the first region takes the palette’s first hue, the next the second, and so on, wrapping after twelve. The same page always looks the same, neighbours differ, and a page of one-line regions reads as hues stepping down the page in order, not as a scatter. A region’s own set colour (above) replaces its ordered hue. - Lines with no region. The lines of a page that have no region ancestor are one implicit region: the page’s own. It takes its hue like any region, at the place its first line has in the reading order, and its lines take the gradient along that order. A word or letter with no line is graded the same way inside its nearest ancestor, else inside the page’s implicit region. - The gradient. Inside a region, the first line in reading order is drawn at the hue’s full strength and the last at a lighter strength (about 0.45), with the lines between them evenly spaced. A region with one line draws it at full strength. The reading order is the working pass’s: the order the pane has chosen when it has one, else the page’s as-written order. A line moved in the order changes shade, and a line out of order breaks the run and stands out. The shade is a strength of the same system colour, never a new RGB, so Light, Dark and Increase Contrast still adapt. - Children as children (recommended). A child takes its parent’s colour: a word takes its line’s shade and a letter its word’s. Each finer level draws INSIDE its parent and lighter than it: the region as a faint wash of its hue with a thin outline, its lines as thin outlines in their shades, and words and letters as hairlines in their line’s shade. A finer level never hides its parents: a page with words still draws its lines and regions under them. Selecting a parent lights its children and dims what is outside it; selecting a child shows its parent’s outline at full strength. The pane’s “What to show” menu carries a legend: each region’s swatch with its gradient, and one line saying the shade is the reading order. - The same hierarchy in lists and text. The Segments list and strip group rows by region: a region row carries its swatch, its lines are indented under it with their shade, and a line discloses its words. Today the list shows one level at a time and opens the next by drilling in (ReadingOrderList, SegmentsPane.asWritten). The Reader marks each region’s block of lines with a rule in the region’s hue down its leading edge, and a highlighted word, line or region uses the same hue.

Prior art: Transkribus and eScriptorium draw regions and the lines inside them together, colour regions by role, and light a region’s lines when it is selected. Aletheia nests PAGE elements by level with a colour per element type and lets the user choose which levels to show. All three keep a parent’s outline visible under its children. This design keeps the nesting and takes the colour from the region instead of the role.

  • source.editor.colour.region-hue — [PARTIAL] (#5426) every region on a page is drawn in its own palette hue, assigned in the working pass’s region order; its lines, words and letters take that hue; a region’s set colour replaces it. The Segments list’s swatches and the Reader’s region rules take the same tone (2026-10-05). Built and pinned by RegionColoursTests and BoxColourThroughTheStoreTests (through SegmentStore and SegmentDisplay.selected); not yet seen in the app, and the set colour is not built.
  • source.editor.colour.reading-order-gradient — [PARTIAL] (#5426) inside a region, lines are shaded from the hue’s full strength to a lighter strength along the working pass’s reading order (the pane’s chosen order, else as written); moving a line in the order changes its shade. Replaces the two-tint alternation. Built on the as-written order and pinned by RegionColoursTests (a reordered order moves the shade); not yet seen in the app, and the Preview does not yet read the pane’s chosen order.
  • source.editor.colour.never-random — [PARTIAL] (#5463) no box’s colour comes from a hash of its own id: the same page draws in the same colours every time, and no line is coloured apart from its region. Built (the hash is deleted) and pinned by RegionColoursTests (renaming every id changes no colour) and BoxColourThroughTheStoreTests (the Inspector’s row is the Preview box’s colour); not yet seen in the app.
  • source.editor.colour.regionless-lines-are-one-region — [PARTIAL] (#5463) lines with no region ancestor are one implicit region of the page: one hue, placed in the region order where their first line falls, with the gradient along their reading order. Built and pinned by RegionColoursTests and BoxColourThroughTheStoreTests; not yet seen in the app.
  • source.editor.hierarchy.children-drawn-as-children — [PARTIAL] (#5426) a child draws inside its parent, in its parent’s colour, lighter than it; showing words or letters never hides the lines and regions they belong to; selecting a parent lights its children, selecting a child shows its parent. Built 2026-10-05 on the image, the PDF page and the SwiftUI canvas, pinned by SegmentHierarchyTests; not yet seen in the app.
  • source.editor.hierarchy.legend — [PARTIAL] (#5426) the pane’s “What to show” menu shows each region’s swatch with its gradient and says that the shade is the reading order. Built 2026-10-05 on the image page (RegionColours.legend, SelectionStyle.regionLegendSwatch); not on the PDF page, not yet seen in the app.
  • source.editor.hierarchy.segments-list-nests — [PARTIAL] (#5426) the Segments list and strip show the same hierarchy: lines indented under their region’s row with its swatch, words disclosed under their line, each row in the colour its box is drawn in. Built 2026-10-05 for the list (pinned by SegmentHierarchyTests and the hosted pane in ImportedPageDrawsItsBoxesTests); the strip and grid carry each picture’s swatch but stay one level; not yet seen in the app.
  • source.editor.hierarchy.reader-shows-regions — [PARTIAL] (#5426) the Reader marks each region’s block of lines with its hue, and a highlighted word, line or region is drawn in that hue. Built 2026-10-05: the Reader’s text rules each block (ReaderRegionRules, test_reader_region_rules.py). Still owed: the highlight in the region’s hue, and the Lines mode; not yet seen in the app.

Test matrix

The click-around leg matters most here, on the Mac, the iPad and the iPhone. Filled for the “reading before editing” block as each behaviour landed (2026-09-27); the editor’s own rows are still owed.

Behaviour Unit Click-around Notes
source.app.one-segment-store SegmentStoreTests.swift (7) not needed A second store is a source-level fact, not a screen one; the unit test that one document’s entry is replaced and no other is what a second store breaks first.
source.app.index-is-the-engines SegmentDisplayTests.swift (8) needed Only a screen shows that a placeholder box is invisible AND that clicking the box after it still edits the right line. The unit tests pin the addresses; a person has to confirm the hit-testing quirk at the page’s top-left corner.
source.app.edits-name-the-chosen-pass none yet needed Unbuilt. The test is “change the shown pass, edit, and the edit lands on the pass you were looking at”.
source.app.curated-pass-stays-on-top DrawingOrderTests.swift needed The unit test pins the ranking; the screen leg is “run a machine pass over a page you have corrected and your boxes stay”.
source.app.overlays-draw-from-the-seam SegmentDisplayTests.swift needed, and it is the whole acceptance The claim is “a page looks the same before and after”, which no unit test can make. Image and PDF, on each platform.
source.app.segment-events-patch-in-place SegmentStoreTests.swift (8 new) needed The unit tests pin the decision; the screen leg is “edit a line in one window and watch the other window’s box change without the page flickering” — a wholesale reload is visible as a flash and nothing else catches it.

Engine-side rows filled 2026-09-27 (source.textedit.*’s first three, plus stale-keeps-your-words; the click-around leg is still to be filled at approval):

Behaviour Engine primitive Test
reader-shows-segments document_text() test_textedit_engine_primitives.py::TestReaderShowsSegmentsUsesTheWorkingPassAndNamedOrder (follows the working pass and order) + test_textedit_reader_blocks.py (9 tests across TestOneDirectionIsOneBlock, TestADirectionChangeStartsANewBlock, TestNonOrientableValuesNeverMerge, TestRegionGrouping: region/direction packaging, boustrophedon one-block-per-line, follows-baseline/alternating never merge)
typing-is-a-new-reading representation.create + provenance_kind_from_ctx test_textedit_engine_primitives.py::TestTypingIsANewReadingSetsTheMakerFromContext (4 tests: human, workflow, refused client-supplied maker, earlier reading unchanged)
return-splits-the-line segment.split test_textedit_engine_primitives.py::TestReturnSplitsTheLineSegmentSplitPrimitive (2 tests: independent anchor + reading_span per part; no caret-to-geometry mapping exists)
stale-keeps-your-words representation.create expected_counting_id → 409 (decided 2026-09-28 by the lead as a default) test_stale_keeps_your_words.py (3 tests: stale write writes nothing and names what counts; Keep Mine against the new basis lands; no token = today, a token without a segment refused) + test_textedit_engine_primitives.py::TestWithoutTheTokenTwoCorrectionsBothLand
backspace-joins-in-reading-order segment.merge test_segments_write_actions.py::TestMergeRefusesAcrossRegions (3 tests: two different regions refused, one region + one no-region refused, same region still merges)

Open questions

Answered (source/source-model.md Rulings 2026-09-19 item 18 + Still open 1 (approved 2026-09-27)): Segments pane approved, built with slice 13. Most were ruled on 2026-09-19: see “Rulings of 2026-09-19” and “Still open” in source-model.md. #5114 and #5115 are answered; see Rulings.

Ruled (maintainer, 2026-10-05): the hierarchy is shown nested, inheriting, lighter (A). Every level shows at once, each inside its parent and lighter than it; selecting a parent lights its children; the Segments list indents lines under their region; the Reader marks each region’s block with its hue (#5426). Region hues stay assigned in region reading order (the built default; the maintainer may revisit fixed per-region hues). The options weighed are kept below for the record.

Weighed (2026-10-05): how the hierarchy is shown (#5426, “Box colour and the segment hierarchy” above). - Recommended, A: nested, inheriting, lighter. A child takes its parent’s colour and is drawn inside it, lighter and thinner: region as a faint wash and outline, lines as thin outlines in their gradient shades, words and letters as hairlines. Every level shows at once; selecting a parent lights its children. The Segments list indents lines under their region; the Reader marks each region’s block with its hue. - B: one level at rest, the parent on demand. Only the finest level shown is drawn at rest, as today; a parent’s outline appears when a child is hovered or selected, and a region’s lines light when it is selected (Transkribus’s habit). Calmer on a dense page, but the structure is not seen at a glance. - C: level by stroke, not by tint. Every level keeps its region’s full hue and differs by stroke instead: region dashed and wide, line solid, word dotted. Strong in Increase Contrast, but it competes with the dashed stroke that already means “a model’s box, not yet confirmed”.

A smaller choice inside A: region hues assigned in region reading order (recommended: the same page always looks the same, and neighbours differ) or kept by a stable hash of the region’s id (a region keeps its hue when another region is added, but colours look arbitrary).

Ruled (maintainer, 2026-10-05, #5467): one canonical page. A page has one canonical model; artifacts and outputs are brought into it and tied back to its segments (regions, lines, words, letters). Drawing, the Segments list and the Reader all read that one page; focusing an artifact shows that artifact’s pass within the page model, and all three follow it together. How each kind of artifact comes into the page model is not yet thought through (the maintainer: the workflows are probably not good yet at producing page-model data); an audit and a spec section come first, before code.

Weighed (2026-10-05): a focused artifact and the other surfaces (#5467). When you click an artifact in the Inspector, should the Segments list and the text follow that pass too, or only Preview? Today only Preview follows it (the 2026-08-27 ruling); the Segments list and the Reader keep the page’s working pass, so the page can show one pass’s boxes beside another pass’s list.

Rulings

  • 2026-10-04 (#5114): the segment focus is the tool bar the Preview already has; there is no new mode. source.editor.segment-focus is retagged from what is built.
  • 2026-10-04 (#5115): the Inspector may run the same audited verbs as the Preview (Combine stays). The rule: shapes are drawn in the Preview, readings are typed in the Reader, and the Inspector shows the selection and may run the same one-path verbs, never a second implementation (source.editor.shapes-in-source-view).
  • 2026-10-04 (#5426, #5463): a box’s colour is its region’s hue, shaded along the working pass’s reading order; nothing is coloured at random; a child is seen as its parent’s child. This spec owns it (“Box colour and the segment hierarchy”); how the hierarchy is drawn is in Open questions.

Triaged from the backlog (2026-10-04)

  • source.editor.multi-selection-moves-together [GAP] (#5236): with several boxes selected, each shows its marquee without handles, and dragging inside any of them moves the whole set together as one ⌘Z. (Handles only on a single selection landed in 18d1c3b8b.)