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.mdfirst. Evidence insource-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.swiftdraws all boxes, display only.Views/Preview/ImageViewer/Regions/RegionInteractionLayer.swiftsits above it and can select (click, shift-click, band), move a region, draw a new box and name it, and rate lines; delete is inZoomableImagePreviewMac+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.swiftis 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.
Editing a page by its text (design; recommended home: the Reader)
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:
- 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.
- For each
source.editor.*andsource.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. - 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-switchesis 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 (PDFText, the icon); star and check are namedStamps; 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
TextEquivor ALTOString@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 linerepresentation_id = None(“read from its words”). -
So a line reading typed in the Reader over such a line creates a
representation.createthat the page does not read: the words still win. Whatever else #5190 builds,an-edited-line-reads-from-itselfhas 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 withWord/TextEquivover 74 lines.) -
source.textedit.word-spans-in-the-line— [OK] (→ #5190; pinned byfichero-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 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 postslineMove, which the app turns into the Inspector’sreading_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 ownrefreshPageon 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 postslineFocusedonce per change of the caret’s line, andwindow.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 onereadingEditper 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: onereadingEditis ONErepresentation.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 isrepresentation.createitself. The joint (2026-09-28): the served page’s OWN script, run in node on the imported Syriac page, posts through its ownnotify(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 andReaderTextEditRunner– whatapplyTextEditruns – 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 carriesexpected_counting_id, the id of the reading that counted when the person began typing.representation.createcompares 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), plustest_textedit_engine_primitives.py::TestWithoutTheTokenTwoCorrectionsBothLand. Built: the page (2026-09-28): onlineCommitted({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 withbasedOn= 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 sendsbasedOnas the token (ReaderTextEdit.newReading); a 409 is answered to the page aslineCommitted({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 andrep-0002named, Keep Mine re-sent against it). A line whose text came from its words sends nobasedOn, 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 ownnotify(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 andReaderTextEditRunner– whatapplyTextEditruns – to the requests the engine takes (ImportedPageDrawsItsBoxesTests.testTheServedPagesOwnMessagesBecomeTheRequestsTheEngineTakes): Keep Mine’s own message isrepresentation.createagainst 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 answeredlineCommitted({ok:false, unreachable:true, reason})and the page is toldengineState({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 toldengineState({reachable:true})and posts its held edits as ordinaryreadingEdits, 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 attbpage 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 bytest_reader_directions.py::test_each_manuscript_line_is_its_own_line_and_the_text_is_unchanged(the recorded Syriac page) andtest_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 –rtlasdir="rtl",ttbaswriting-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 isttbin 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: everySegmentEditRunnerchange postsdidChange, and the Reader’sSegmentChangeObserverpatches 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 bettb-lr, seelanguages-scripts-signs.mdsource.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 inresources/fonts/PROVENANCE.md, 1.3 MB in all – serves them atGET /api/fonts(the list, in fallback order) andGET /api/fonts/{file}(font/otf, behind the engine’s auth like thumbnails), and the Reader declares each by@font-faceand names them after the system fonts in its stack. Pinned byfichero-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.workingSegmentsis the one pass-filtered read, andReadingOrderStore.loadFollowingre-reads when the working pass changes. Pinned through the real pane in the library window’s tree byfichero/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:LibraryContentKindisdocuments | 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.segmentsis 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 theLibraryContentKindcase. 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 explicitdocument_idsor aparent_idwhose descendants are walked — withkind,pass_idandinclude_furniturefilters, answering{items, count, total, document_ids}. Pinned bytest_scoped_segment_listing.py(11 tests). The parts worth knowing:Database.segments_pagedoes 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_atcannot do that: rows written in one transaction share a timestamp), and it is the same order_segment_order_keyuses, so a scoped list reads the way the page reads. Notbox_index, which is not a column at all: a real row’s engine index lives inmetadata["box_index"]and is only meaningful within one pass. limitis capped at 1000 in the signature (source.store.bounded-reads), anddocument_idscomes 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.segmentscase 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 fromSegmentDisplay(source.app.overlays-draw-from-the-seam), so there is one decision about what to draw. But the drawing itself isOCRGeometryOverlay’s SwiftUICanvason an image andPDFAnnotationsquares on a PDF page, and the reason is recorded where the second one lives: “AppKit’sPDFViewhas 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 usesPDFView, 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 SwiftUICanvasfor images,PDFKitannotations 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 replacingPDFViewwith 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 sameRegionEditResult.registerUndo. One duplicate remains, named so it is not mistaken for compliance: the Inspector checksselection.artifactId == artifactIditself 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.ttbis a column beside the line;rtlruns 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, fromGET /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 bytest_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
InlineWordsnow follows the line’s direction (inline-text-in-its-direction, #5411); the SwiftUI layer’sInlineWordText(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. Attbline 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. Anrtlline 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 onDocumentOverlay.Box). Pinned byPreviewVerticalPageTests. 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 fromregions-under-lines). A region on a page without lines still sets its reading. Built 2026-10-04, Mac image page (InlineWords.fittedSizefits both axes;DocumentOverlay.setsTextInline). Pinned byPreviewVerticalPageTests. 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 ofthin-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 byPreviewVerticalPageTests. 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, plusFocusedRegionSelection.sharedfor 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 andPageContentPane+SourceHighlightnames “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:RegionSelectionholds 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 asSegmentSelection(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 sendexpected_versionwithout 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; andexpectedVersionsis all or nothing, sincesegment.deletetakes 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 isRegionSelection.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 aRegionSelection;WindowState.focusedRegionSelectionwith focus, offer and release rules; pinned byfichero/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 onboundsDidChangeand handed over through aTaskhop, 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 reachessegment.convert_and_edit(undoable=True,invert=_invert_convert, domains segment/artifact/document), and the first edit CONVERTS and edits in the same step — pinned byTestTheFirstEditConvertsAndEdits.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 originalexpected_versionand 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 (branchspec/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) flagstest_artifact_regions_edit.pyas 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}/undoinverts any audited action, and redo is the undo of the undo (pinned intest_action_undo.py). The app knows how to do its half too —registerUndois 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 anArtifactResponseand never said which audit row it wrote, so the app had noaudit_idto 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}/regionsanswersArtifactRegionsEditResponse, a SUPERSET ofArtifactResponseso every existing caller decodes it unchanged, carryingaudit_id. Pinned byTestTheEditNamesTheActionItWrote.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.ActionUndobridges an audited action to the systemUndoManager, and move, delete and combine in the Source view register it with the audit id their own response carries. The one subtlety isUndoManager’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 byActionUndoTestsagainst a REALUndoManager(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 sameRegionEditResult.registerUndothe 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 emitssegment.converted, ⌘Z emitssegment.updated, and ⇧⌘Z re-applies the move. The engine half is pinned byfichero-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 reachSegmentStore, 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.revisionsis 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 bySegmentStoreTests.testAPatchBumpsThatDocumentsRevisionAndNoOtherandSegmentStoreTests.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’sopenapi_surface_generated.pygenerates 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_postand the rest. Worth stating plainly because it is the same property that madecheck_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,_undeleteand_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:updaterequiresexpected_versionanddeletea 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 bytest_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_readingis 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 inZoomableImagePreviewMac:magnifierEnabled,loupeEnabled,loupeMagnification,loupeSize,panelMagnification,panelHeight,magnifierLocked,loupeLocked,annotationsEnabled,regionsEnabled,inlineTextEnabled. Of these,annotationsEnabledandregionsEnabledbecome 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 issegment.createof 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 onesegment.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 onesegment.updatewith ⌘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’spagePoint) and decided by the image’s own rule (ReshapeDrag.press), so the sameSegmentShapes.reshapeis 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 – andMenuShortcutUniquenessTestsnow 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.mergePlanturns aSegmentSelectioninto onesegment.mergerequest 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 bySegmentMergePlanTests. 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’sSegmentPassMismatchErrorrather 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.planturns an attribute and aSegmentSelectioninto thesegment.updaterequests to send, and refuses three ways — nothing selected, a selection left over from another page, a selected segment with no known version — each tested inSegmentEditCommandTests(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.updatetakes 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. NowPATCH /api/segments→segment.update_many: one audit row for N rows, all or nothing (a stale line refuses the whole edit, assegment.deleterefuses its list), the cascade’s facts through the same writersegment.updateuses so a bulk edit is no way aroundassert_known_script, and one undo restoring every line through its inversesegment.restore_versions. Pinned bytest_segment_update_many.py(10 tests, includingTestOneEditIsOneAction.test_one_undo_restores_every_lineandTestAllOrNothing.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-levelexpected_versionand a bulk edit’s versions live insideupdates[]— 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.planturns an attribute and aSegmentSelectioninto thesegment.updaterequests to send, and refuses three ways — nothing selected, a selection left over from another page, a selected segment with no known version — each tested inSegmentEditCommandTests(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.updatetakes 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. NowPATCH /api/segments→segment.update_many: one audit row for N rows, all or nothing (a stale line refuses the whole edit, assegment.deleterefuses its list), the cascade’s facts through the same writersegment.updateuses so a bulk edit is no way aroundassert_known_script, and one undo restoring every line through its inversesegment.restore_versions. Pinned bytest_segment_update_many.py(10 tests, includingTestOneEditIsOneAction.test_one_undo_restores_every_lineandTestAllOrNothing.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-levelexpected_versionand a bulk edit’s versions live insideupdates[]— 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.planturns an attribute and aSegmentSelectioninto thesegment.updaterequests to send, and refuses three ways — nothing selected, a selection left over from another page, a selected segment with no known version — each tested inSegmentEditCommandTests(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.updatetakes 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. NowPATCH /api/segments→segment.update_many: one audit row for N rows, all or nothing (a stale line refuses the whole edit, assegment.deleterefuses its list), the cascade’s facts through the same writersegment.updateuses so a bulk edit is no way aroundassert_known_script, and one undo restoring every line through its inversesegment.restore_versions. Pinned bytest_segment_update_many.py(10 tests, includingTestOneEditIsOneAction.test_one_undo_restores_every_lineandTestAllOrNothing.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-levelexpected_versionand a bulk edit’s versions live insideupdates[]— 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 ONEreading_order.place, which already moves an existing entry as one row and one action.ReadingOrderMove.placeturns a target index into theafter_entry_idit 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 byReadingOrderMoveTestsin both directions, including the conversion from SwiftUI’sonMoveoffsets, 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 byRegionColoursTestsandBoxColourThroughTheStoreTests(throughSegmentStoreandSegmentDisplay.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 byRegionColoursTests(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 byRegionColoursTests(renaming every id changes no colour) andBoxColourThroughTheStoreTests(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 byRegionColoursTestsandBoxColourThroughTheStoreTests; 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 bySegmentHierarchyTests; 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 bySegmentHierarchyTestsand the hosted pane inImportedPageDrawsItsBoxesTests); 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-focusis 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.)